DEV Community

Cover image for How to Find and Fix N+1 Queries in Laravel
Accreditly
Accreditly

Posted on

How to Find and Fix N+1 Queries in Laravel

An N+1 problem rarely shows itself in development. Your local database has five rows, the page renders instantly and the query log looks harmless. Production has five thousand rows, and the same page now runs five thousand and one queries. Laravel makes this bug easy to write, but it also gives you everything you need to catch it early and fix it for good.

What an N+1 actually looks like

Here is the classic version:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name;
}
Enter fullscreen mode Exit fullscreen mode

One query fetches the posts. Then every pass through the loop triggers another query, because author is a lazy-loaded relationship and nothing has loaded it yet. Ten posts means eleven queries. A thousand posts means a thousand and one.

The nasty part is that nothing is visibly broken. The page renders, the data is correct and the only symptom is response time creeping up as the table grows. That is why these bugs ship: they pass code review and they pass QA.

Make Laravel shout about it

Laravel can throw an exception the moment a lazy load happens, and you should turn that on today:

// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

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

With this enabled, the loop above throws a LazyLoadingViolationException naming the model and the relationship, instead of quietly running an extra query. The ! $this->app->isProduction() guard means local and staging environments fail loudly while production carries on serving pages.

If you want production visibility without production exceptions, log the violations instead:

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

There is also Model::shouldBeStrict(), which enables this check plus protection against silently discarded attributes and access to missing attributes. Worth a look once lazy loading is under control.

Fix it with eager loading

The fix is almost always with():

$posts = Post::with('author')->get();
Enter fullscreen mode Exit fullscreen mode

Two queries total, regardless of row count: one for the posts, one whereIn for all the authors. The loop from earlier now touches memory, not the database.

Relationships nest with dot syntax, and you can load several at once:

$posts = Post::with(['author.profile', 'comments'])->get();
Enter fullscreen mode Exit fullscreen mode

You can also trim the columns you pull back, as long as you include the keys the relationship needs to match on:

$posts = Post::with('author:id,name')->get();
Enter fullscreen mode Exit fullscreen mode

Leave out id there and the relationship silently returns null, which is a fun afternoon of debugging you can skip.

Counts without loading anything

A common false fix is eager loading an entire relationship just to count it. If the page only shows "42 comments", use withCount():

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}
Enter fullscreen mode Exit fullscreen mode

One extra subquery, no comment models hydrated, and your memory usage stays flat.

When the models are already loaded

Sometimes the collection arrives from code you do not control and the queries have already run. Lazy eager loading covers that:

$posts->load('author');
Enter fullscreen mode Exit fullscreen mode

Same two-query outcome, just performed after the fact. Handy inside a controller that receives models from a package or a route binding.

Pin the query count in a test

Fixing an N+1 once is good. Stopping it coming back is better. Laravel's test suite can assert exactly how many queries an endpoint runs:

public function test_post_index_runs_a_fixed_number_of_queries(): void
{
    Post::factory()
        ->count(50)
        ->for(User::factory(), 'author')
        ->create();

    $this->expectsDatabaseQueryCount(3);

    $this->get('/posts')->assertOk();
}
Enter fullscreen mode Exit fullscreen mode

Run it once to find the real number for your endpoint, then pin it. The exact figure does not matter much. What matters is that it stays constant whether the factory creates 50 rows or 500, and that the test fails the moment someone reintroduces a lazy load inside a loop.

Recap

In this tutorial you've seen how N+1 queries happen, how Model::preventLazyLoading() turns them into loud failures during development, and how with(), withCount() and load() fix them properly. The combination of strict mode locally and a pinned query count in your test suite means the next N+1 gets caught before it ever reaches a customer.

Do you run lazy loading prevention in production too, or only in development? Share your setup in the comments below.

Top comments (0)