The N+1 you cannot see
Everyone knows to eager load. The queries that actually take sites down are the ones hiding in an accessor, a Blade partial, or a policy — and there is a one-line switch that finds all of them.
Contents
The worst N+1 I ever found was in a permission check. Every row in a table called $user->can('view', $order), the policy loaded $order->customer->account, and a 50-row page ran 151 queries. Nobody spotted it for eight months because the code looked completely innocent — there was no loop over a relationship anywhere in sight.
That is the shape of the problem. The obvious N+1 gets caught in review. The ones that survive are the ones that do not look like queries at all.
The one line everybody should add today
Laravel can simply refuse to lazy load:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
In development and in your test suite, any access to a relationship that was not eager loaded throws LazyLoadingViolationException, pointing at the exact line. In production it does nothing, so a missed case degrades rather than breaking a customer’s page.
Run your test suite after adding this. Mine found eleven the first time, in a codebase I thought I knew well.
If you would rather log than throw in production:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
Log::warning('Lazy loaded', [
'model' => $model::class,
'relation' => $relation,
]);
});
Where they hide
In accessors. This is the most common one by a distance.
public function getDisplayNameAttribute(): string
{
return $this->profile->nickname ?? $this->name; // a query, every time
}
The call site is $user->display_name. It looks like a property. It is a database round trip.
In Blade partials. @include('order.row', ['order' => $order]) and three levels down something reaches for $order->customer->country->name. The controller eager loaded customer. Nobody eager loaded customer.country.
In policies and gates. As above. Authorisation runs per row and nobody thinks of it as data access.
In API resources. $this->whenLoaded('customer') is the correct tool and it is widely ignored in favour of $this->customer, which loads it whether or not you meant to.
In count() on a collection. $post->comments->count() loads every comment row to count them. $post->comments()->count() runs SELECT COUNT(*). One character.
Fixing them properly
Eager loading is the first answer, and load() covers the case where you already have the model:
$orders = Order::with(['customer.country', 'items.product'])
->latest()
->paginate(50);
But the sharper tools are the aggregate ones, because the best query is the one you do not run:
$posts = Post::query()
->withCount('comments') // comments_count
->withSum('orders as revenue', 'total') // revenue
->withExists('subscriptions as subscribed') // subscribed (bool)
->get();
One query, no relationship loaded, no thousands of model objects hydrated to produce a number.
When you need the latest child rather than all of them, latestOfMany turns a classic N+1 into a join:
public function latestOrder(): HasOne
{
return $this->hasOne(Order::class)->latestOfMany();
}
And when a relationship is genuinely unavoidable inside a loop, loadMissing() on the collection before the loop is better than fighting the architecture.
Seeing them before your users do
Laravel Debugbar or Telescope in development. Query count per request, visible while you work. If you only install one thing, install this.
A query count assertion in tests. This is the one that stops regressions:
public function test_order_index_is_efficient(): void
{
Order::factory()->count(30)->create();
DB::enableQueryLog();
$this->actingAs($this->user)->get('/orders')->assertOk();
$this->assertLessThan(12, count(DB::getQueryLog()));
}
An assertion like that on your three heaviest endpoints will catch the accessor somebody adds next quarter. It is the only mechanism on this list that works when you are not looking.
A threshold alert in production. Log any request over, say, 50 queries, with the route. Two lines in a middleware, and it turns “the site feels slow sometimes” into a list of routes.
The uncomfortable bit
N+1 does not show up in development, because your seeded database has twelve orders and twelve extra queries take four milliseconds. It shows up when a customer has 4,000, on a table that is now 200 times larger, on a shared database server on a Monday morning.
Which is the real lesson: seed realistically. If your local database cannot reproduce a slow page, your local database is the bug. Everything else on this list is a way of compensating for that.