Skip to content

The N+1 Query Problem: How I Actually Catch It in Laravel

The N+1 Query Problem: How I Actually Catch It in Laravel

Short version for the impatient: the N+1 query problem is almost never a mystery, it’s just invisible until you count. Add a query counter to one test and you’ll find it in an afternoon.

Now the longer version, which involves me looking slightly stupid.

A while back I had an orders dashboard that felt fine locally and took about four seconds in production. I assumed it was the database being slow, so I spent an evening adding indexes to a table that already had every index it needed. Then I finally turned on query logging and watched 1,903 queries scroll past for a single page load. Fifty orders, each one fetching its customer, each one counting its line items. The database wasn’t slow. I was asking it the same question four hundred times and being surprised it took a while to answer.

That’s N+1. One query to get a list, then N more queries because you touched a relationship inside a loop. Every ORM has a version of this. Eloquent’s version is friendlier than most, which is exactly why it’s so easy to ship.

The shape of the bug I keep writing

Here’s the controller. Nothing wrong with it at a glance:

public function index()
{
    $orders = Order::latest()->paginate(50);

    return view('orders.index', compact('orders'));
}

And here’s the Blade template that quietly ruins it:

@foreach ($orders as $order)
    <tr>
        <td>{{ $order->reference }}</td>
        <td>{{ $order->customer->name }}</td>
        <td>{{ $order->items->count() }} items</td>
    </tr>
@endforeach

One query for the page of orders. Then fifty for $order->customer. Then fifty more for $order->items. That’s 101 round trips to render a table.

What makes this hard to spot is that it’s split across two files. The controller looks clean. The template looks clean. The bug lives in the gap between them, and it doesn’t exist until someone writes a @foreach. I’ve reviewed pull requests that introduced this and approved them, because the diff was three lines of Blade and none of those lines said “run fifty queries”.

The fix is the boring one everyone already knows:

$orders = Order::query()
    ->with('customer:id,name')
    ->withCount('items')
    ->latest()
    ->paginate(50);

Three queries now. One for the orders, one for the customers, one for the item counts. In the template, $order->items->count() becomes $order->items_count, which is a column on the result rather than a fresh query. Laravel’s eager loading docs cover the syntax properly if you want the full list of what with() accepts.

Eager loading fixes the query count, not necessarily the page

This is the part I got wrong for a good two weeks, and it cost me a lot of confused staring.

The first time I fixed an N+1 by adding with(), the query count dropped from 1,903 to 4 and the page got slower. Not much slower. But it didn’t get faster, which was the entire point of the exercise.

The reason: I’d eager loaded a relationship with a text column in it. Something like with('customer') on a customers table that had a notes column holding a few kilobytes of free text per row. Fifty small queries became one query that dragged half a megabyte across the wire and then hydrated fifty full Eloquent models out of it. Fewer queries, more bytes, more object construction.

So two habits I picked up:

Select only what you need. with('customer:id,name') instead of with('customer'). The id is mandatory, by the way, because Eloquent needs it to match children back to parents. Leave it out and you get an empty relation and a very confusing ten minutes.

Use withCount() when you only want a number. Loading four thousand line items to call count() on them in PHP is a waste of memory that the database will happily do for you in one pass.

The general principle is that query count is a proxy metric. It’s a good proxy, and I still use it as my first signal, but the thing you actually care about is time and memory. Sometimes those move together. Sometimes they don’t. I hit a similar version of this when I was benchmarking search, which I wrote up in my post on Postgres full-text search. The fastest option on paper lost once real row sizes showed up.

The one setting that stops this at the source

If you take one thing from this post, take this. Laravel can throw an exception the moment you lazy load a relationship, so the bug fails loudly in development instead of quietly in production.

In AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

That’s it. Touch $order->customer without eager loading it and you get a LazyLoadingViolationException with the model and relation named in the message. The Eloquent strictness docs explain the related toggles too, including preventSilentlyDiscardingAttributes, which has caught its own share of my mistakes.

The ! $this->app->isProduction() bit matters. You want the exception in local and CI. You do not want your checkout page throwing 500s at 2am because someone missed a with() in a code path nobody tested. Fail loud where it’s cheap, degrade quietly where it isn’t.

I resisted this setting for longer than I should have, because the first time I switched it on it broke about nine pages at once and I assumed the setting was too aggressive. It wasn’t. Those nine pages were all doing N+1. It was just an honest bill arriving all at once.

Two things to know before you flip it. Turning it on in an existing app is a day of work, not a five minute change, so budget for that. And if you have a genuinely intentional lazy load, Model::preventLazyLoading(false) inside a narrow scope beats disabling the whole thing.

Catching it in CI, not in code review

Humans are bad at counting queries by reading diffs. I’ve proven this repeatedly. So I let the test suite do it.

The trick is that DB::listen gives you a hook on every query, and a counter around a request is about six lines:

use Illuminate\Support\Facades\DB;

test('orders index stays under a query budget', function () {
    Order::factory()->count(30)->hasItems(4)->create();

    $queries = 0;
    DB::listen(function () use (&$queries) {
        $queries++;
    });

    $this->get('/orders')->assertOk();

    expect($queries)->toBeLessThan(10);
});

The number 10 is arbitrary and that’s fine. The point isn’t precision, it’s the direction of the alarm. If someone adds a relationship access to that Blade template, this test goes from 4 queries to 34 and fails. Nobody has to notice anything during review.

I put one of these on every page that renders a list. It’s maybe fifteen minutes of work per route and it has caught more real regressions than any linter I’ve installed.

For local development there’s also beyondcode/laravel-query-detector, which watches requests as you browse and warns you when a relationship gets hit repeatedly. It can output to the log, the browser console, or Debugbar. I keep it on log output, because browser alerts during development make me want to throw my laptop into the sea.

When eager loading is the wrong answer entirely

Not every repeated query wants with().

Large exports are the obvious one. If you’re iterating 200,000 rows, eager loading the relation means holding all of it in memory at once, and your job dies on a box with 512MB. lazyById() keeps memory flat while still chunking sensibly:

Order::with('items')->lazyById(500)->each(function (Order $order) {
    // one chunk of 500 in memory at a time
});

Aggregates are the other one I see people get wrong. If the answer is a single number, don’t hydrate models to get it. This:

$total = Order::with('items')->get()
    ->sum(fn ($order) => $order->items->sum('price'));

is a great way to load your entire orders table into PHP so you can add up a column. The database will do it in one pass without constructing a single object.

And sometimes the honest answer is that a page is asking for too much, and it needs a dedicated read query or a summary table rather than a cleverer ORM call. I’ve stopped treating “write raw SQL for this one endpoint” as a defeat.

Background jobs deserve a mention here too, since moving heavy work off the request is often the real fix. I wrote about how I run that side of things in my Horizon setup post.

What to do this week

Pick your slowest list page. Add Model::preventLazyLoading(! $this->app->isProduction()) to AppServiceProvider, load the page locally, and see what explodes. Fix whatever the exception names, then write one query-budget test around that route so it stays fixed.

That’s a couple of hours, most of it mechanical, and in my experience it finds more wall-clock time than any index you were about to add. Most of the Laravel work I do these days starts with exactly this kind of unglamorous measurement pass, and you can see some of what comes out of it in my project work.

The database was never the problem. The loop was.