Skip to content

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.

5 min read

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 queries

If 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 hydrated

The 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

  • preventLazyLoading on 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.

Related questions

Does eager loading always fix an N+1?
It replaces many queries with two, which is usually right. It is the wrong fix when you only need an aggregate - loading ten thousand related rows to count them is one query instead of ten thousand, and still far more work than asking the database for a number.
Is preventLazyLoading safe to turn on?
In development and test environments, yes, and it is the single highest-value line in this article. In production it throws on a code path you missed, so most teams enable it everywhere except production - which catches the fault in CI while leaving production degraded rather than broken.
Why does the N+1 not show up in development?
Because a hundred extra queries against a local database with forty rows costs a few milliseconds. The fault is not latency-dependent, it is data-dependent, and development data is the one place the data is small.
Can a package find these for us?
Detection packages help and they run at runtime, which means they only see the code paths you exercise. They are a good safety net and a poor substitute for a query-count assertion on the endpoints that matter.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page