Skip to content
4 min read

Eloquent N+1 queries: find them, then fix them

The slowest Laravel pages are usually one loop issuing a query per row. How to make N+1 visible, fix it with eager loading, and stop it coming back.

On this page
  1. The shape of it
  2. Make it impossible to miss
  3. Fix it by stating what the page needs
  4. Eager loading is not free either
  5. Then look at the three queries you have left
  6. Keeping it fixed
  7. Why it survives so long

The page takes four seconds. The slow query log is empty, because none of the queries are slow. There are just eight hundred of them.

This is the most common performance bug in any Eloquent codebase, and it hides in the one place nobody looks, which is code that reads exactly like what you meant.

The shape of it

php
$leads = Lead::latest()->limit(100)->get();          // 1 query

foreach ($leads as $lead) {
    echo $lead->assignee->name;                      // 1 query. Each time. 100 total.
}

101 queries, each fast, together slow. Round-trip latency, connection overhead and PHP's own per-result work dominate, and none of it shows up in a slow query log that thresholds on duration.

It compounds quietly too. $lead->assignee->team->name inside that same loop is another hundred queries, and nothing in the source makes the difference visible.

Make it impossible to miss

The fix for the class of bug is not an eager load. It is making lazy loading loud.

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

public function boot(): void
{
    // Local and CI only: lazy loading now throws instead of quietly costing you.
    Model::preventLazyLoading(! $this->app->isProduction());
}

Now $lead->assignee inside a loop raises LazyLoadingViolationException the moment you write it, naming the model and the relation. You find the bug at the keyboard rather than in a graph three weeks later, which is a difference of about two orders of magnitude in what it costs to fix.

Pair it with a query count you can see:

php
DB::listen(fn ($q) => app('queries')->increment());
// then log it per request, and fail a test above a threshold

A page whose query count rises with the number of rows rendered is N+1 by definition. That is a property you can test once and keep forever.

Fix it by stating what the page needs

php
$leads = Lead::with(['assignee:id,name', 'source:id,label'])
    ->latest()
    ->limit(100)
    ->get();                                          // 3 queries, regardless of rows

Three queries whether there are ten rows or ten thousand.

Two details earn their place there. The column list in assignee:id,name loads two columns rather than a whole user row, and the foreign key has to be in that list or the relation cannot be matched back, which is a five-minute confusion the first time it happens.

And with versus load: with eager loads as part of the original query, for when you know up front what the page needs. load does the same thing to a collection you already have, for when the decision depends on something you only learned after fetching.

For counts, do not load rows in order to count them:

php
$leads = Lead::withCount('activities')->get();        // activities_count, no rows loaded

And for "the latest related record per row", which is otherwise a loop by another name:

php
$leads = Lead::with('latestActivity')->get();         // via a HasOne ofMany relation

Eager loading is not free either

The opposite mistake shows up within about a week of fixing the first one.

php
$leads = Lead::with(['assignee', 'source', 'activities', 'documents', 'notes'])->get();

Five relations loaded for a list view that renders two of them. You are now pulling thousands of rows into PHP memory in order to display none of them, and a well-intentioned fix has turned into a different performance problem that is harder to spot, because the query count looks great.

Load what the view renders. If two views need different relations, let each one decide. A model that eager loads globally through $with is convenient right up until a page needs none of them, and then it is convenient for nobody.

Then look at the three queries you have left

Once the count is down from eight hundred to three, the duration of those three starts to matter, and that is a different exercise. Read the plan, confirm the indexes exist, and check that the eager-load query is not itself doing a full scan. The method is in reading a slow MySQL query plan.

Order matters here. Tuning an index while a page issues a query per row is optimising the wrong number entirely, in the same way that compressing images does nothing for a page whose LCP element is text.

Keeping it fixed

Three habits, all cheap.

preventLazyLoading on in local and CI, because the exception is the whole mechanism and everything else here is downstream of it.

Assert query counts in feature tests for any page that renders a list. Laravel gives you expectsDatabaseQueryCount(), called at the top of the test, and the test fails if the real count differs. A DB::listen counter with a cap works too. Either way the number stops drifting quietly.

And read the count in review. "How many queries does this page issue now" is a better review question than "does this look efficient", because the first one has an answer and the second one invites a shrug.

Why it survives so long

N+1 is not a subtle problem, it is an invisible one. The loop reads naturally, every individual query is genuinely fast, and the cost only appears at a scale nobody develops against.

So make it visible. Throw on lazy loading outside production, count queries per request, eager load exactly the relations the page renders.

After that the fix is one line, and more importantly it stays fixed, because the next instance announces itself the moment someone writes it.

Frequently asked

What is an N+1 query problem?

One query fetches a list of N records, and then accessing a relation on each record issues one more query per record — N+1 in total. It is invisible in the code, which reads like a simple loop, and it scales with the size of the list, so it behaves perfectly in development with ten rows and badly in production with a thousand.

How do I find N+1 queries in Laravel?

Enable Model::preventLazyLoading() in your local and testing environments, which throws an exception the moment a relation is lazily loaded. Alongside that, log the query count per request, or use a debug bar that shows it. A page whose query count rises with the number of rows displayed is the definition of the problem.

Does eager loading always make things faster?

No. Eager loading a relation the page never renders adds work, and eager loading a huge relation pulls far more data into memory than the page needs. Load the relations the view actually uses, constrain them with select, and for counts use withCount instead of loading the rows to count them.

What is the difference between with and load in Eloquent?

with eager loads the relation as part of the original query, before you have the models. load does the same thing afterwards, on a collection you already have. Use with when you know up front what the page needs, and load when the decision depends on something you learn after fetching.

Found this useful?Share on XShare on LinkedIn

Related reading