What the N+1 Problem Actually Is

Take a blog index that lists 50 posts and shows each author’s name. The controller looks harmless:

// Controller
$posts = Post::latest()->take(50)->get();

// Blade
@foreach ($posts as $post)
    <h2>{{ $post->title }}</h2>
    <p>by {{ $post->author->name }}</p>
@endforeach

That runs 51 queries, not one:

select * from posts order by created_at desc limit 50          -- 1 query
select * from users where id = 3 limit 1                       -- +1
select * from users where id = 7 limit 1                       -- +1
select * from users where id = 3 limit 1                       -- +1 (again!)
... 47 more

One query to fetch the parents, then N more — one per parent — to fetch each child. Hence N+1. Note the third and fourth lines: Eloquent will happily fetch the same author twice, because each model instance resolves its relationship independently.

The reason this survives code review is that it is invisible in the code. $post->author->name looks like a property access. It is actually a database round trip — and every round trip carries network latency, which is what really kills the page.

Spotting It in a Running App

You cannot fix what you cannot see. Pick one of these and keep it on in local development.

Quick and dirty — log every query. Drop this in AppServiceProvider::boot() behind an environment check:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

if (app()->environment('local')) {
    DB::listen(function ($query) {
        Log::debug($query->sql, [
            'bindings' => $query->bindings,
            'time'     => $query->time . 'ms',
        ]);
    });
}

Count queries on a single request when you just want a number:

DB::enableQueryLog();

$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
    $post->author->name;
}

dd(count(DB::getQueryLog()));   // 51

Laravel Debugbar (composer require barryvdh/laravel-debugbar --dev) shows the query count and duration on every page, and flags duplicate queries. It is the fastest way to catch N+1 across a whole application rather than one endpoint at a time.

Fix 1 — Eager Loading With with()

with() tells Eloquent to fetch the related rows up front, in one extra query, using a single WHERE IN.

$posts = Post::with('author')->latest()->take(50)->get();
select * from posts order by created_at desc limit 50
select * from users where id in (3, 7, 11, 12, 15)   -- one query, deduplicated

51 queries become 2. The Blade template does not change at all — $post->author now reads from memory instead of hitting the database.

Load several relationships at once by passing an array:

$posts = Post::with(['author', 'tags', 'category'])->get();

If a relationship is loaded on essentially every page, add it to the model’s $with property so it is always eager loaded: protected $with = ['author'];. Use this sparingly — it also fires on queries that do not need the relationship.

Nested and Selective Eager Loading

Dot notation walks down the relationship tree. This loads comments and each comment’s author in two extra queries rather than hundreds:

$posts = Post::with('comments.author')->get();

Eager loading pulls every column by default. On wide tables, restrict the select — but always include the key columns or Laravel cannot match the rows back up:

// id is required for Eloquent to stitch the relationship together
$posts = Post::with('author:id,name,avatar')->get();

You can also constrain what gets eager loaded with a closure:

$posts = Post::with([
    'comments' => function ($query) {
        $query->where('approved', true)
              ->latest()
              ->limit(5);
    },
])->get();

limit() inside an eager-load closure applies to the whole result set on older Laravel versions, not per parent. Laravel 11 and 12 support per-parent limits properly, but if you are on an older release use a package such as staudenmeir/eloquent-eager-limit or fetch the latest rows in a separate query.

Fix 2 — Lazy Eager Loading With load()

Sometimes you do not know whether you need the relationship until after the parents are fetched — a conditional branch, or a collection handed to you by something else. load() eager loads onto an existing collection.

$posts = Post::latest()->take(50)->get();

if ($request->boolean('include_authors')) {
    $posts->load('author');       // one extra query, not 50
}

// Only load what is missing
$posts->loadMissing('author');

loadMissing() is the safe default inside reusable code — it skips relationships that are already loaded instead of re-querying them.

Fix 3 — withCount Instead of Counting in PHP

Showing “12 comments” next to each post is its own flavour of N+1:

// Bad — one COUNT query per post
{{ $post->comments()->count() }}

// Worse — loads every comment row into memory just to count them
{{ $post->comments->count() }}

// Good — one query for all posts, via a subquery
$posts = Post::withCount('comments')->get();
{{ $post->comments_count }}

The same pattern exists for other aggregates, and you can constrain and alias them:

Post::withCount([
    'comments',
    'comments as approved_count' => fn ($q) => $q->where('approved', true),
])->get();

Post::withSum('orders', 'total')->get();     // orders_sum_total
Post::withMax('comments', 'created_at')->get();
Post::withExists('comments')->get();         // comments_exists — cheaper than a count

Fix 4 — Make Laravel Throw

The best fix is not finding N+1 in production — it is making it impossible to write. Laravel can throw an exception the moment a relationship is lazy loaded:

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

public function boot(): void
{
    // Throws LazyLoadingViolationException in dev, silent in production
    Model::preventLazyLoading(! app()->isProduction());
}

Now any unloaded relationship access blows up locally and in CI with a message naming the exact model and relationship. You fix it once, add the with(), and it can never regress silently.

If you would rather log than crash in production, hook the violation handler:

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    Log::warning('Lazy loaded relation', [
        'model'    => get_class($model),
        'relation' => $relation,
    ]);
});

Turn preventLazyLoading() on in a new project on day one. Retrofitting it into a large legacy codebase means fixing hundreds of violations at once — still worth doing, but do it on a branch.

Gotchas That Reintroduce It

  • API resources. A PostResource that references $this->author->name reintroduces N+1 even when the controller eager loaded correctly — unless the collection route also loads it. Use whenLoaded('author') so the resource degrades instead of querying.
  • Accessors. A computed attribute that touches a relationship runs on every model in the collection. Same problem, hidden one level deeper.
  • Blade partials. A shared @include that reaches for $post->category->name will N+1 on every page that forgets to load it.
  • Model events and observers. An observer that loads a relationship on saved fires once per record during a bulk import.
  • Polymorphic morphTo. Eager loading these needs with(['commentable' => [...]]) or morphWith(), because the related tables differ per row.
// API resource that never triggers a query of its own
public function toArray($request): array
{
    return [
        'id'     => $this->id,
        'title'  => $this->title,
        'author' => new UserResource($this->whenLoaded('author')),
        'comments_count' => $this->whenCounted('comments'),
    ];
}

Checklist

SituationUse
You know up front you need the relationwith('relation')
Collection already fetchedload() / loadMissing()
You only need a numberwithCount() / withExists()
You only need a sum or averagewithSum() / withAvg()
Deep relation chainwith('a.b.c')
Wide related tablewith('author:id,name')
Stop it happening ever againModel::preventLazyLoading()

Measure before and after. If a page dropped from 300 queries to 4, that is not a micro-optimisation — that is usually the difference between a two-second page and a two-hundred-millisecond one.