The N+1 Problem Is Not Just a Beginner Mistake
Every Laravel developer learns about N+1 queries early. Yet in production codebases with layered services, Blade components, and Livewire, they creep back in — often invisibly. The fix is not just slapping with() everywhere. It requires understanding when Eloquent fires queries, how the relationship cache works, and where deduplication breaks down.
How Eloquent's Relationship Cache Works
When you call $post->comments, Eloquent checks $this->relations on the model. If the key exists (even as an empty collection), it returns the cached result. If not, it fires a query.
This means:
$post->comments; // fires query, caches result
$post->comments; // returns cached collection — no query
$post->load('comments'); // re-queries and overwrites cache
$post->setRelation('comments', collect()); // manually set, prevents any query
Understanding this cache is the foundation of every optimization below.
Eager Loading: with() vs load() vs loadMissing()
with() is for query-time loading. load() re-queries unconditionally. loadMissing() is the underused hero — it only fires a query if the relation is not already cached.
// Bad: always re-queries even if already loaded
$posts->each(fn($p) => $p->load('author'));
// Good: skips already-loaded relations
$posts->loadMissing('author');
In service classes that receive models from different call sites, loadMissing() is the safe default.
Nested Eager Loading and Constraint Closures
Dot notation handles nesting, but constraints require closures:
Post::with([
'comments' => fn($q) => $q->where('approved', true)->latest()->limit(5),
'comments.author:id,name,avatar',
])->get();
Note the column selection on comments.author — always select the foreign key (id) plus only what you need. Omitting the FK breaks the relation hydration silently.
The withCount and withExists Shortcuts
Avoid loading full collections just to count them:
// Bad: loads all comments into memory to count
$post->comments->count();
// Good: single subquery, adds comments_count attribute
Post::withCount('comments')->get();
// Even cheaper when you only need a boolean
Post::withExists('comments')->get(); // adds comments_exists
withExists compiles to a correlated EXISTS subquery, which the query planner can short-circuit on the first matching row.
Detecting N+1 in CI with Telescope and preventLazyLoading()
Laravel ships a first-class guard:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
This throws a LazyLoadingViolationException the moment any unloaded relation is accessed outside production. Pair it with a Pest architecture test:
it('does not lazy load in feature tests', function () {
Model::preventLazyLoading();
// run your feature test suite — any violation throws
});
For production observability, Telescope's query panel groups duplicate queries. If you see the same query repeated with different bindings, that is your N+1.
Load-Once Guards in Service Classes
When a service method is called from multiple entry points, guard against redundant loads:
class PostEnricher
{
public function enrich(Post $post): Post
{
$post->loadMissing(['author', 'tags', 'category']);
// safe to access now — no extra queries if already loaded
return $post;
}
}
This pattern is especially valuable in Filament resource pages where the same model flows through multiple actions and infolist entries.
Chunked Processing and Eager Loading
When iterating large datasets, chunk() with with() is correct; cursor() is not — cursor returns a lazy generator of individual models with no batch context, so every relation access fires a query.
// Good: each chunk of 500 eager-loads relations in 2 queries
Post::with('author')->chunk(500, function ($posts) {
$posts->each(fn($p) => ProcessPost::dispatch($p));
});
// Dangerous for relations: each model is hydrated alone
Post::cursor()->each(fn($p) => $p->author->name); // N+1
Use cursor() only when you need memory efficiency on flat, relation-free queries.
Takeaways
- Use
loadMissing()in services — it is the safe, idempotent alternative toload(). - Always select the FK column when constraining nested eager loads.
- Prefer
withCount/withExistsover loading collections for aggregates. - Enable
Model::preventLazyLoading()in non-production environments and enforce it in CI. chunk()+with()is the correct pattern for large dataset iteration;cursor()is not relation-safe.- Profile with Telescope query grouping before optimizing — measure first.