- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You build an activity feed. Comments belong to posts and to videos, so you reach for a polymorphic relation. The list is slow, Debugbar shows 300 queries, and you already know the fix: add ->with('commentable'). You redeploy. The query count drops to a dozen. You move on.
A week later the feed is slow again. You open Debugbar and there it is: another wall of queries, this time loading the author of every post and the channel of every video. Your ->with('commentable') is still there. It just never covered the relations inside the polymorphic target. That gap is the part the Laravel docs walk past in one sentence, and it is where most polymorphic N+1 bugs actually live.
The setup: one relation, many tables
Here is the shape. A Comment points at either a Post or a Video through a morphTo.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
The comments table carries commentable_type and commentable_id. A row might hold App\Models\Post / 42 or App\Models\Video / 7. One column pair, two destination tables.
The Post has an author. The Video has a channel and tags. Different relations on different models, reached through the same commentable accessor. That asymmetry is the whole story.
Where the N+1 actually hides
Load the feed with no eager loading and count the damage:
use App\Models\Comment;
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$comments = Comment::latest()->limit(100)->get();
foreach ($comments as $comment) {
echo $comment->commentable->title;
}
echo count(DB::getQueryLog()); // 101
One query for the comments, then one lazy query per comment to fetch its commentable. Classic N+1. The standard fix does work here:
$comments = Comment::with('commentable')
->latest()
->limit(100)
->get();
Eloquent loads the comments, groups them by commentable_type, and fires one query per distinct type. Two types in the feed means two follow-up queries, whatever the row count. That is the behavior worth internalizing: eager-loaded morphTo costs one query per type, not per row. Ten thousand comments spread across posts and videos still resolve in three queries total.
So far the docs are right and the feed is fast. The trap is one layer down.
The trap: nested relations on a polymorphic target
You now render the author of each post and the channel of each video. Reach for the nested syntax you use everywhere else:
// This throws for at least one type.
$comments = Comment::with('commentable.author')
->latest()
->limit(100)
->get();
Post has author, so that half resolves. Video has no author method, so Eloquent throws BadMethodCallException: Call to undefined method App\Models\Video::author(). The dot syntax assumes every polymorphic target exposes the same relation name. Polymorphic targets almost never do.
The instinct after that error is to drop the nesting and load the inner relations in the loop instead. That is exactly how the second N+1 sneaks back in:
$comments = Comment::with('commentable')
->latest()->limit(100)->get();
foreach ($comments as $comment) {
$target = $comment->commentable;
if ($target instanceof Post) {
echo $target->author->name; // lazy query
} elseif ($target instanceof Video) {
echo $target->channel->name; // lazy query
}
}
The top-level commentable is eager. Every author and channel underneath it is lazy. You are back to one query per row for the nested layer, and Debugbar shows the wall again.
morphWith: per-type nested eager loading
The tool the docs mention once and then leave alone is morphWith. Pass a closure to with(), type-hint the MorphTo, and declare which nested relations load for which concrete type.
use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\MorphTo;
$comments = Comment::query()
->with(['commentable' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Post::class => ['author'],
Video::class => ['channel', 'tags'],
]);
}])
->latest()
->limit(100)
->get();
Now Eloquent groups by type as before, and for each type it eager-loads the relations you named for that type. Posts get their authors in one query. Videos get their channels and tags in their own queries. Nothing loads lazily in the loop.
The values in the array are ordinary eager-load specs, so nested dots work per type:
$morphTo->morphWith([
Post::class => ['author.profile'],
Video::class => ['channel', 'tags'],
]);
Counting the difference
Numbers make the payoff concrete. Wrap each version and read the log length. Feed of 100 comments split across posts and videos, each post with an author, each video with a channel and tags.
DB::enableQueryLog();
// ... run one of the versions ...
dump(count(DB::getQueryLog()));
| Version | Queries |
|---|---|
| No eager loading | 100+ |
with('commentable'), nested in loop |
100+ |
with(['commentable' => morphWith(...)]) |
~6 |
The morphWith run: one query for comments, one per type to resolve the targets, then one per nested relation per type. Bounded by how many types and relations you declared, never by row count. The exact total shifts with your relation graph, but it stays flat as the feed grows, which is the property you want.
Constraining per type with constrain()
morphWith handles which relations load. When you also need a where clause that differs per type, constrain is the matching tool. Show comments on published posts and active videos only:
$comments = Comment::query()
->with(['commentable' => function (MorphTo $morphTo) {
$morphTo->constrain([
Post::class => fn ($q) => $q->where('published', true),
Video::class => fn ($q) => $q->where('status', 'active'),
])->morphWith([
Post::class => ['author'],
Video::class => ['channel', 'tags'],
]);
}])
->get();
A plain closure constraint on a morphTo cannot do this, because the same closure would run against every target table and reference columns that only exist on one of them. constrain keeps each type's clause scoped to its own query.
For counts rather than full relations, morphWithCount mirrors morphWith and adds the *_count attributes per type.
Make the framework tell on you
You do not want to find these gaps by watching Debugbar. Turn lazy loading into a hard error in local and CI so a missing morphWith fails the test instead of shipping. Put this in AppServiceProvider::boot:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
Any relation touched without being eager-loaded now throws LazyLoadingViolationException with the model and relation name. The nested author access from the loop example stops being a silent 100-query tax and becomes a failing test on the pull request.
The takeaway
Three rules for polymorphic relations under load:
- Eager-loaded
morphTocosts one query per type. That part is fine and the docs cover it. - Nested relations under a
morphTodo not ride along with a plainwith('commentable'). Declare them per type withmorphWith, or they fall back to lazy queries in your loop. - Turn on
preventLazyLoadingeverywhere except production so the framework fails loudly the moment a polymorphic nested relation goes unloaded.
The dot syntax breaks on polymorphic targets for a good reason: the types genuinely differ. morphWith is how you tell Eloquent what each one needs.
If this was useful
The reason this bug hides so well is that the query concern lives inside Eloquent, tangled up with your read model, so a missing eager load looks like ordinary code until the query count spikes. Keeping the fetch strategy at the edge, behind a repository the domain does not know about, is what lets you tune it (morphWith, constrain, chunked reads) without touching the code that renders the feed. That separation between the framework's data access and the work your application actually does is what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)