5 min read

Laravel N+1 Queries: How to Find and Fix the 141-Query Page

Laptop screen showing lines of code during a debugging session

Last month I shipped an order dashboard for a client. Five rows in my local seed data, page rendered in 40ms, tests green, off it went. Two days later the client asks why the page "sometimes takes four seconds." I pulled up Laravel Debugbar on a copy of their production data and the number at the bottom made me laugh out loud: 141 queries. One page. One request.

That's the N+1 problem, and if you've written more than two Eloquent queries in your life, you've shipped it at least once. The good news: it's one of the easiest performance bugs to catch, if you let Laravel yell at you.

What's actually happening

The classic version looks completely innocent:

$orders = Order::latest()->take(20)->get();

foreach ($orders as $order) {
    echo $order->customer->name;
}

One query fetches the orders. Then every pass through the loop accesses customer, which wasn't loaded, so Eloquent helpfully lazy-loads it. That's one extra query per row. Twenty orders means twenty-one queries. Five thousand rows means five thousand and one. Nothing is broken. The data is correct. The page just gets slower as the table grows, which is exactly why these bugs survive code review and QA.

Make it fail loudly in development

This is the single most useful line I add to every Laravel project now:

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

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

With that in place, the loop above throws a LazyLoadingViolationException naming the model and the relationship, with a stack trace pointing at the exact line. Local and staging fail loudly; production keeps serving pages, which matters because some third-party packages lean on lazy loading and I don't want to find that out via a 500 error on a Tuesday.

Want visibility in production without the exceptions? Log the violations instead:

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    logger()->warning("Lazy load of {$relation} on " . get_class($model));
});

If you want to go further, Model::shouldBeStrict() also catches silently discarded attributes and access to missing ones. Worth turning on once lazy loading is under control.

The fix: load once, use many

The fix itself is one method call. Eager loading replaces the per-row query with a single additional query, no matter how many rows you have:

$orders = Order::with('customer:id,name')
    ->latest()
    ->take(20)
    ->get();

Two queries total. One for the orders, one WHERE id IN (...) for every customer at once.

One gotcha that cost me an afternoon last year: when you select specific columns on the relation, the foreign key has to be in the list. with('customer:id,name') works. Drop the id and every relation silently comes back null. No error, no exception, just mysteriously blank output. Eloquent matches relations on that key, so if it's not selected, there's nothing to match on.

Counts deserve their own mention, because the "obvious" fix is eager loading the whole relation just to display a number. If the page only shows "12 items", don't load twelve item models per row. Use SQL:

$orders = Order::with('customer:id,name')
    ->withCount('items')
    ->get();

// $order->items_count is computed in SQL, no models hydrated

There's withSum, withAvg and friends if you need totals. Honestly, half the "slow Eloquent page" reports I've seen were someone counting a collection inside a loop.

Pin the query count so it stays fixed

Fixing it once is fine. Stopping the next person from reintroducing it is better. Laravel's test suite can assert an exact query count:

public function test_dashboard_runs_a_fixed_number_of_queries(): void
{
    Order::factory()->count(50)->for(Customer::factory())->create();

    $this->expectsDatabaseQueryCount(3);

    $this->get('/dashboard')->assertOk();
}

The exact number doesn't matter much. What matters is that it stays the same whether the factory creates 50 rows or 500, and that the test fails the day someone adds a lazy load inside a loop. That test has caught two regressions since I wrote it. Cheap insurance.

One caveat before you eager-load everything

Eager loading multiplies rows into a single IN query, and that query is only fast if the foreign key columns are actually indexed. Eloquent doesn't add indexes for you. If the "after" version is still slow, check the table side of the equation. I wrote up the indexing half of this in how I cut database query time with MySQL indexing, and it pairs directly with what's above.

I keep these three snippets (strict mode, the violation logger, the query-count test) in Snippet Ark so pasting them into a new project takes seconds. You probably should too. And if your page still crawls after all this, the bug probably isn't Eloquent. That's a job for the slow query log.