-
Notifications
You must be signed in to change notification settings - Fork 11.7k
Description
Laravel Version
12.39.0
PHP Version
8.4
Database Driver & Version
postgreSQL 18
Description
When calling whereDoesntHaveMorph('relation', '*') on a nullable morphTo relationship, Eloquent adds an OR ... IS NULL to include “no morph assigned” rows.
Currently, that OR is appended outside the grouped relation clause.
This produces ungrouped SQL, so prior or subsequent where conditions are not consistently applied depending on ordering, due to SQL operator precedence.
The result set can be wider than intended.
Imagine a model Notification with a nullable morphTo notifiable to Video and Post, plus a boolean column is_urgent.
Query 1:
App\Models\Notification::where('is_urgent', false)
->whereDoesntHaveMorph('notifiable', '*')
->toRawSql();
Produces SQL similar to:
select * from "notifications"
where
"is_urgent" = 0
and (
("notifications"."notifiable_type" = 'App\Models\Video' and not exists (select * from "videos" where "notifications"."notifiable_id" = "videos"."id"))
or ("notifications"."notifiable_type" = 'App\Models\Post' and not exists (select * from "posts" where "notifications"."notifiable_id" = "posts"."id"))
)
or "notifications"."notifiable_type" is null
Query 2:
App\Models\Notification::whereDoesntHaveMorph('notifiable', '*')
->where('is_urgent', false)
->toRawSql();
Produces:
select * from "notifications"
where
(
("notifications"."notifiable_type" = 'App\Models\Video' and not exists (select * from "videos" where "notifications"."notifiable_id" = "videos"."id"))
or ("notifications"."notifiable_type" = 'App\Models\Post' and not exists (select * from "posts" where "notifications"."notifiable_id" = "posts"."id"))
)
or "notifications"."notifiable_type" is null
and "is_urgent" = 0"
These two queries return different results even though they are logically meant to be equivalent.
Expected Behavior
The OR for the “no morph assigned” case (IS NULL) should be grouped together with the other per-type relation branches, so it does not escape the relation condition group.
Both examples above should instead produce:
...
and (
("notifications"."notifiable_type" = 'App\Models\Video' and not exists (select * from "videos" where "notifications"."notifiable_id" = "videos"."id"))
or ("notifications"."notifiable_type" = 'App\Models\Post' and not exists (select * from "posts" where "notifications"."notifiable_id" = "posts"."id"))
or "notifications"."notifiable_type" is null
)
Actual behavior
The OR ... IS NULL is currently appended outside the grouping of the relation branches, which changes how prior/subsequent where conditions bind and leads to inconsistent results.
Code location and root cause
- File:
src/Illuminate/Database/Eloquent/Concerns/QueriesRelationships.php - Method:
hasMorph()
Problem
- The per-type branches are correctly grouped inside a where(...) closure.
- The extra branch that handles the “null morph” case is appended afterwards.
Question
Is this behavior intended? If not, I can prepare a PR with tests to address it. This would be my first Laravel PR, so any guidance is welcome.
Steps To Reproduce
A minimal reproduction repository is available here:
Repo: [Eloquent] whereDoesntHaveMorph('relation', '*') generates ungrouped OR
- Clone the repository and set it up. (see README)
- Open Tinker and execute the example queries to compare the generated SQL.