Skip to content
LaravelBlog

laravel/framework: [13.x] Fix `loadMissing()` with nested relations on mixed `morphTo` models

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Laravel framework pull request #61845 fixes a silent data-corruption bug in Eloquent's loadMissing() method. When a nested relation path passes through a morphTo — for example, an activity feed whose subject may be a Post, a Video, or any other model — loadMissing() resolved the second level of the relation using only the class of the first model in the collection. Models of other classes could then be handed related records that belong to a different type entirely. The fix groups the intermediate models by class before continuing down the relation path. It ships as a bug fix with no public API changes, and plain load() and with() were never affected.

The bug: one model's class applied to every model

Under the hood, loadMissing() walks dot-separated paths level by level through a protected loadMissingRelation() method. After each level, it gathers the related models for the next segment into a single flat collection and recurses. That recursion is where the trouble starts: a collection's eager loader builds its query from the first model it holds ($collection->first()->newQueryWithoutRelationships()). For a homogeneous collection that is invisible. But a morphTo relation returns whatever class each row points at, so the flattened set can contain a Post and a Video side by side. The nested relation — images in the report — is then resolved as the first model's relation, and the query is constrained with the first model's morph type. Because the results are matched back onto the models in the collection by key, any model whose primary key collides with the first model's type receives that model's records.

With auto-incrementing integer keys, collisions are the norm, not the exception: Post #1 and Video #1 both exist. Calling $activities->loadMissing('subject.images') therefore gave the video the post's images — no exception, no warning, just the wrong rows attached to your models.

The fix: group by class before descending

// Before: one mixed collection, one query built from the first model
$this->loadMissingRelation(new static($models), $path);

// After: each class descends the path separately
$models->groupBy(fn ($model) => $model::class)->each(
    fn ($models) => $this->loadMissingRelation(new static($models), $path)
);

Every class now gets its own recursive call, so the query for the next level is always built from a homogeneous set and constrained with the correct morph type. If the collection only contains one class, nothing changes: groupBy() produces a single group and the exact same code path runs as before. The PR also adds an integration test, testLoadMissingWithNestedRelationOnMorphToOfDifferentTypes, which reproduces the post/video scenario and locks the corrected behavior in.

Seeing it in practice

// activities: id, subject_id, subject_type  (morphTo)
// images:    id, imageable_id, imageable_type, name

$post  = Post::find(1);
$video = Video::create();

$post->images()->create(['name' => 'post image']);
$video->images()->create(['name' => 'video image']);

Activity::create(['subject_type' => Post::class,  'subject_id' => $post->id]);
Activity::create(['subject_type' => Video::class, 'subject_id' => $video->id]);

$activities = Activity::query()->get();
$activities->loadMissing('subject.images');

$activities[0]->subject->images->pluck('name')->all(); // ['post image']
$activities[1]->subject->images->pluck('name')->all();
// ['post image'] on 13.34.0 — wrong, that is the Post's image
// ['video image'] after the fix

Both subjects carry the primary key 1, which is all it takes to trigger the bug. If you need to stay on 13.34.0 for a while, the workaround is to group by class yourself:

$activities->map->subject
    ->groupBy(fn ($subject) => $subject::class)
    ->each(fn ($subjects) => $subjects->loadMissing('images'));

Calling load('subject.images') also returns correct data, but it unconditionally re-runs the query, defeating the "missing" optimization that loadMissing() exists for.

Why only loadMissing() was affected

The standard eager-loading machinery behind with() and load() already partitions morphed models by type when it descends into nested relations: the morphTo eager load runs one query per related type and loads each type's nested relations separately. loadMissing() uses a hand-rolled recursion — it exists to skip relations that are already loaded — and that recursion was flattening everything into a single mixed collection before handing it back to the loader. Only that path was broken.

Upgrade impact

If you are upgrading from v13.34.0, pull the next release containing #61845 and you are done: there are no signature changes, new methods, or configuration switches — loadMissing() simply returns the relations it always should have. Two behavioral notes. First, mixed-class nested loads now issue one query per related class rather than a single query built from the first model's class; that mirrors what with() has always done and is the cost of correctness. Homogeneous collections still execute exactly one query. Second, if any of your tests asserted the buggy output — for instance, expecting a video to carry the post's images — they will now fail and should be corrected rather than suppressed.

Takeaways

  • loadMissing('subject.images') on a collection whose morphTo subjects are of mixed classes resolved the nested relation with the first model's class, so models with colliding keys received another type's records.
  • PR #61845 groups intermediate models by $model::class before continuing the relation path, so each class is loaded with its own correctly constrained query.
  • with() and load() were always correct; only loadMissing()'s recursion was affected.
  • No public API changes — upgrading from 13.34.0 is drop-in. Mixed-type loads now run one query per class instead of one incorrect query.
  • Silent wrong-data bugs deserve a regression test: the new integration test reproduces the exact post/video collision.

Sources

More from this release