What the N+1 Problem Actually Is
Take a blog index that lists 50 posts and shows each author’s name. The controller looks harmless:
// Controller
$posts = Post::latest()->take(50)->get();
// Blade
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2>
<p>by {{ $post->author->name }}</p>
@endforeachThat runs 51 queries, not one:
select * from posts order by created_at desc limit 50 -- 1 query
select * from users where id = 3 limit 1 -- +1
select * from users where id = 7 limit 1 -- +1
select * from users where id = 3 limit 1 -- +1 (again!)
... 47 moreOne query to fetch the parents, then N more — one per parent — to fetch each child. Hence N+1. Note the third and fourth lines: Eloquent will happily fetch the same author twice, because each model instance resolves its relationship independently.
The reason this survives code review is that it is invisible in the code. $post->author->name looks like a property access. It is actually a database round trip — and every round trip carries network latency, which is what really kills the page.
Spotting It in a Running App
You cannot fix what you cannot see. Pick one of these and keep it on in local development.
Quick and dirty — log every query. Drop this in AppServiceProvider::boot() behind an environment check:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
if (app()->environment('local')) {
DB::listen(function ($query) {
Log::debug($query->sql, [
'bindings' => $query->bindings,
'time' => $query->time . 'ms',
]);
});
}Count queries on a single request when you just want a number:
DB::enableQueryLog();
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
$post->author->name;
}
dd(count(DB::getQueryLog())); // 51Laravel Debugbar (composer require barryvdh/laravel-debugbar --dev) shows the query count and duration on every page, and flags duplicate queries. It is the fastest way to catch N+1 across a whole application rather than one endpoint at a time.
Fix 1 — Eager Loading With with()
with() tells Eloquent to fetch the related rows up front, in one extra query, using a single WHERE IN.
$posts = Post::with('author')->latest()->take(50)->get();select * from posts order by created_at desc limit 50
select * from users where id in (3, 7, 11, 12, 15) -- one query, deduplicated51 queries become 2. The Blade template does not change at all — $post->author now reads from memory instead of hitting the database.
Load several relationships at once by passing an array:
$posts = Post::with(['author', 'tags', 'category'])->get();If a relationship is loaded on essentially every page, add it to the model’s $with property so it is always eager loaded: protected $with = ['author'];. Use this sparingly — it also fires on queries that do not need the relationship.
Nested and Selective Eager Loading
Dot notation walks down the relationship tree. This loads comments and each comment’s author in two extra queries rather than hundreds:
$posts = Post::with('comments.author')->get();Eager loading pulls every column by default. On wide tables, restrict the select — but always include the key columns or Laravel cannot match the rows back up:
// id is required for Eloquent to stitch the relationship together
$posts = Post::with('author:id,name,avatar')->get();You can also constrain what gets eager loaded with a closure:
$posts = Post::with([
'comments' => function ($query) {
$query->where('approved', true)
->latest()
->limit(5);
},
])->get();limit() inside an eager-load closure applies to the whole result set on older Laravel versions, not per parent. Laravel 11 and 12 support per-parent limits properly, but if you are on an older release use a package such as staudenmeir/eloquent-eager-limit or fetch the latest rows in a separate query.
Fix 2 — Lazy Eager Loading With load()
Sometimes you do not know whether you need the relationship until after the parents are fetched — a conditional branch, or a collection handed to you by something else. load() eager loads onto an existing collection.
$posts = Post::latest()->take(50)->get();
if ($request->boolean('include_authors')) {
$posts->load('author'); // one extra query, not 50
}
// Only load what is missing
$posts->loadMissing('author');loadMissing() is the safe default inside reusable code — it skips relationships that are already loaded instead of re-querying them.
Fix 3 — withCount Instead of Counting in PHP
Showing “12 comments” next to each post is its own flavour of N+1:
// Bad — one COUNT query per post
{{ $post->comments()->count() }}
// Worse — loads every comment row into memory just to count them
{{ $post->comments->count() }}
// Good — one query for all posts, via a subquery
$posts = Post::withCount('comments')->get();
{{ $post->comments_count }}The same pattern exists for other aggregates, and you can constrain and alias them:
Post::withCount([
'comments',
'comments as approved_count' => fn ($q) => $q->where('approved', true),
])->get();
Post::withSum('orders', 'total')->get(); // orders_sum_total
Post::withMax('comments', 'created_at')->get();
Post::withExists('comments')->get(); // comments_exists — cheaper than a countFix 4 — Make Laravel Throw
The best fix is not finding N+1 in production — it is making it impossible to write. Laravel can throw an exception the moment a relationship is lazy loaded:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throws LazyLoadingViolationException in dev, silent in production
Model::preventLazyLoading(! app()->isProduction());
}Now any unloaded relationship access blows up locally and in CI with a message naming the exact model and relationship. You fix it once, add the with(), and it can never regress silently.
If you would rather log than crash in production, hook the violation handler:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
Log::warning('Lazy loaded relation', [
'model' => get_class($model),
'relation' => $relation,
]);
});Turn preventLazyLoading() on in a new project on day one. Retrofitting it into a large legacy codebase means fixing hundreds of violations at once — still worth doing, but do it on a branch.
Gotchas That Reintroduce It
- API resources. A
PostResourcethat references$this->author->namereintroduces N+1 even when the controller eager loaded correctly — unless the collection route also loads it. UsewhenLoaded('author')so the resource degrades instead of querying. - Accessors. A computed attribute that touches a relationship runs on every model in the collection. Same problem, hidden one level deeper.
- Blade partials. A shared
@includethat reaches for$post->category->namewill N+1 on every page that forgets to load it. - Model events and observers. An observer that loads a relationship on
savedfires once per record during a bulk import. - Polymorphic
morphTo. Eager loading these needswith(['commentable' => [...]])ormorphWith(), because the related tables differ per row.
// API resource that never triggers a query of its own
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),
];
}Checklist
| Situation | Use |
|---|---|
| You know up front you need the relation | with('relation') |
| Collection already fetched | load() / loadMissing() |
| You only need a number | withCount() / withExists() |
| You only need a sum or average | withSum() / withAvg() |
| Deep relation chain | with('a.b.c') |
| Wide related table | with('author:id,name') |
| Stop it happening ever again | Model::preventLazyLoading() |
Measure before and after. If a page dropped from 300 queries to 4, that is not a micro-optimisation — that is usually the difference between a two-second page and a two-hundred-millisecond one.