N+1 Queries in Laravel: Find Them Before Production Does
The N+1 is the most common performance fault in Laravel and the easiest to reintroduce. Where it hides, how to make it fail a test rather than a page, and when eager loading is wrong.
The N+1 is the first performance fault anybody learns about in Laravel and the one we still find most often in production. Not because teams do not know what it is - because knowing what it is does not stop it coming back.
What it actually is
One query to fetch a collection, then one more per item in that collection:
$orders = Order::latest()->take(50)->get(); // 1 query
foreach ($orders as $order) {
echo $order->customer->name; // 50 queries
}Fifty-one queries where two would do. On a local database with forty rows it costs nothing. On production it is the endpoint everybody complains about.
The fix everybody knows:
$orders = Order::with('customer')->latest()->take(50)->get(); // 2 queriesIf the article stopped here it would be the same article as everyone else's. The interesting part is why this keeps happening in codebases where every developer already knows the above.
Where it actually hides
In an accessor. This is the one that fools people, because the call site looks like string formatting rather than a database access:
public function getDisplayNameAttribute(): string
{
return $this->customer->name . ' (' . $this->customer->country->code . ')';
}Nothing at the call site says "query". Worse, accessors frequently run during serialisation, so an API endpoint that looks like it loads one relation issues two per row.
In a Blade partial reused somewhere new. The partial was written for a detail page, where loading one relation is one query. Somebody includes it in a loop on an index page. The diff that caused the regression does not contain a single query.
Behind a conditional. The relation is eager-loaded in the controller that
was profiled. A second controller returns the same resource without the
with(), because it was written by someone who did not know the resource
touched a relation at all.
Inside a policy. Authorisation runs per item. A policy that reads
$user->team->settings is a query per item in the collection you are
authorising.
In a job, per record. Queue workers make N+1s invisible - nobody is waiting on the page, so the only symptom is a queue that drains more slowly than it should and a database with a load nobody can attribute.
Make it fail loudly, in one line
This is the most valuable change in this article:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());Lazy loading now throws a LazyLoadingViolationException in development and
test. An N+1 stops being a thing you notice on a graph and becomes a thing that
fails in CI, in the pull request that introduced it, with a stack trace
pointing at the line.
Most teams enable it everywhere except production, because a violation you missed is better as a slow page than as a 500. That is a reasonable default and it is worth revisiting once the codebase is clean - a production exception is how you find the code path your tests do not cover.
Then make it stay fixed
Prevention catches the fault while the developer is still holding it. Regression protection catches it later. You want both, and the second is three lines:
it('lists orders without an N+1', function () {
Order::factory()->count(20)->create();
DB::enableQueryLog();
$this->get('/orders')->assertOk();
expect(DB::getQueryLog())->toHaveCount(4);
});A test that asserts a number rather than a range, on the endpoints that carry traffic. When somebody removes an eager load, the build fails with a count that went from four to twenty-four, and the cause is in the diff they are looking at.
The objection to this is that the number is brittle. That is the feature: you want to be told when the query count changes, and updating the assertion is the moment you decide whether the change was intentional.
When eager loading is the wrong answer
Eager loading replaces N queries with one. Sometimes the right number is zero.
You only need a count. $post->comments->count() loads every comment to
count them. withCount('comments') asks the database for a number:
$posts = Post::withCount('comments')->get();
// $post->comments_count, no rows hydratedThe same applies to sums, maximums and existence. withSum, withMax and
whereHas all keep the work in SQL.
You only need the latest one. Loading a customer's entire order history to show their most recent order is a relation with a constraint, not a full eager load.
You are paginating something enormous. with() on a relation that has
thousands of rows per parent turns one problem into a memory problem. Chunk it,
or restructure the query so the database does the filtering.
The data is denormalisable. A counter column maintained on write is not inelegant - it is the correct answer when a value is read a thousand times per write and has to be sorted on.
A short checklist
preventLazyLoadingon outside production.- Query-count assertions on your highest-traffic endpoints.
- Audit your accessors: any of them that touch a relation are N+1 generators wearing a disguise.
- Check policies and API resources, not just controllers.
- Before adding
with(), ask whether you need the rows at all or just a number.
None of this is difficult. It is the difference between an application that is fast because someone profiled it last quarter and one that is fast because it cannot silently stop being fast.
If the queries are already in production and nobody knows which ones are costing you, that is the work we do - and it starts with measurement rather than with a list of best practices.
