Skip to content

fix: resolve a nested relation morph check against its own model - #3306

Open
OzanKurt wants to merge 5 commits into
yajra:masterfrom
OzanKurt:fix/nested-morph-relation
Open

fix: resolve a nested relation morph check against its own model#3306
OzanKurt wants to merge 5 commits into
yajra:masterfrom
OzanKurt:fix/nested-morph-relation

Conversation

@OzanKurt

Copy link
Copy Markdown
Contributor

Follow up to the note on #3300. Depends on #3300, so it is stacked on that branch and its two commits show up here until #3300 is merged. The morph fix itself is the last commit.

Problem

isMorphRelation() always resolves the relation against the model the data table was built from, even when it is called for a nested relation inside a whereHas() callback. At that point the relation belongs to the related model, not to the root one.

When the root model happens to declare a morph relation under the same name as a plain relation of the nested model, the nested one is treated as a morph and whereHasMorph() is called on a relation that is not morphed:

Call to undefined method Illuminate\Database\Eloquent\Relations\HasOne::getMorphType()

Repro with the test models: User::user() is a morphTo while Post::user() is a belongsTo, so searching posts.user.name on User::with('posts.user') fatals.

The opposite direction, a genuine nested morph not being detected, turns out to be harmless. Laravel's has() already routes a MorphTo to hasMorph($relation, '*'), so whereHas() produces the same per type existence queries. There is a test for that case so it stays that way.

Solution

The morph check now resolves against the model of the query being compiled: the related model inside a whereHas() callback, the root model otherwise. This is the same context that #3300 introduced for resolving relation names, so both now agree on which model a nested segment belongs to.

Changes

  • isMorphRelationOf() performs the check against a given model, and isMorphRelation() keeps its signature and delegates using the root model, so an override of it in an application keeps working.
  • modelOf() returns the model of the query being compiled, defaulting to the root one.
  • compileQuerySearch() resolves the context once and uses it for both the relation name and the morph check.
  • tests/Integration/NestedMorphRelationTest.php covers the fatal case and a genuine nested morph relation. The first one fails without the fix.

Notes

  • Nothing changes at depth 1, where the relation already belongs to the root model.
  • composer stan passes and the existing MorphToRelationTest passes unchanged.

Relations are defined in camel case while column names are usually
written in snake case, so a column named child_table.name never
matched the eager loaded childTable relation and fell through to a
plain query search on a table that is not joined.

The camel case form is now used when the literal relation name is not
an eager loaded one, which makes both spellings work without changing
relations that already resolve under their literal name.

Closes yajra#2324
The eager loads of the root query are keyed by their full path, e.g.
user.childTable, so a nested segment never matched on its own and the
raw snake name reached whereHas(), which fataled with an undefined
method on the related model.

A nested relation is now resolved against the model that declares it,
which also covers a nested relation that is not eager loaded, while the
root level keeps resolving against the eager loads as before.
Camel casing the whole dotted path failed on a path mixing a literal
snake case relation with a camel case one, e.g. post_user.nestedHeart,
which fell through to a plain column and produced an unknown column
error when ordering.

Each segment is now matched on its own, preferring the eager load that
matches literally, and relations are detected with Model::isRelation()
so that ones registered via resolveRelationUsing() are found too.
isMorphRelation() always checked the model the data table was built
from, so a plain nested relation sharing its name with a morph relation
of the root model was sent to whereHasMorph() and fataled with an
undefined getMorphType() on the actual relation.

The check now uses the model of the query being compiled, which is the
related model inside a where has callback.
method_exists() misses a relation registered via resolveRelationUsing(),
so such a relation was never recognised as a morph one.
@OzanKurt
OzanKurt force-pushed the fix/nested-morph-relation branch from 6b1aa40 to 3ef1842 Compare September 5, 2026 18:45
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant