N+1 is what happens when you make one query to fetch a list (say, User::all()) and then make one extra query per item inside a loop. Instead of 1 query, the database sees N+1.

What it looks like

The classic shape:

PHP
$users = User::all();
foreach ($users as $user) {
    echo $user->profile->bio;
}

With 50 users this code makes 51 queries: one for users, plus 50 lookups against profiles.

The shape of the problem is easier to see as a picture:

Diagram showing one initial SELECT for users followed by N follow-up SELECTs for each user's profile.
N+1: one parent query plus N child queries.

How to detect it

Three approaches, simple to systematic:

  1. Laravel Telescope — in development, it flags queries with an "N+1" badge.
  2. DB::listen() in AppServiceProvider — log every query and grep the output.
  3. Model::preventLazyLoading() — throws when any relation is loaded lazily, perfect for tests.

A quick comparison of when each is useful:

Tool When to reach for it Catches Cost
Telescope Local dev, exploratory Most N+1 patterns Slows the app a little
DB::listen() Targeted profiling, CI Anything you log Manual log review
preventLazyLoading() Test suite All lazy loads, by error Forces you to fix immediately

How to fix it

Eager loading

The simplest path is with():

PHP
$users = User::with('profile')->get();

Now there are exactly two queries: users and profiles WHERE user_id IN (...).

Lazy eager loading

If the collection is already loaded and you only later realize you need a relation:

PHP
$users->load('profile');

Constraining an eager load

You can narrow an eager-loaded relation so you do not pull more than you need:

PHP
$users = User::with(['posts' => function ($q) {
    $q->where('published', true)->latest()->limit(5);
}])->get();

A real-world example

Here is a service class I refactored last year that started life as a slow report and ended up as a single-digit-millisecond endpoint after the eager-loading pass. The key change is two lines, but the surrounding shape is what makes it survive future contributors:

PHP app/Services/AuthorReportService.php
<?php

namespace App\Services;

use App\Models\Author;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;

final class AuthorReportService
{
    public function __construct(private readonly int $publishedSince)
    {
    }

    public function build(): Collection
    {
        return Author::query()
            ->whereHas('posts', fn (Builder $q) => $q->where('published_at', '>=', $this->publishedSince))
            ->with([
                'profile',
                'posts' => fn (Builder $q) => $q
                    ->where('published_at', '>=', $this->publishedSince)
                    ->latest('published_at'),
                'posts.tags',
                'posts.comments' => fn (Builder $q) => $q->where('approved', true),
            ])
            ->withCount(['posts as published_posts_count' => fn (Builder $q) => $q->whereNotNull('published_at')])
            ->orderByDesc('published_posts_count')
            ->get()
            ->map(fn (Author $author) => [
                'id'          => $author->id,
                'name'        => $author->name,
                'bio'         => $author->profile?->bio,
                'post_count'  => $author->published_posts_count,
                'recent'      => $author->posts->take(3)->map(fn ($post) => [
                    'id'        => $post->id,
                    'title'     => $post->title,
                    'tags'      => $post->tags->pluck('slug')->all(),
                    'replies'   => $post->comments->count(),
                ]),
            ]);
    }
}

The interesting parts:

  • One whereHas() filters authors at the SQL level — no in-PHP filtering after the fetch.
  • The with(['posts' => …, 'posts.tags', 'posts.comments' => …]) chain is the entire point: nested relations + per-relation constraint.
  • withCount projects a synthetic column we can sort by without an extra query.
  • The shape of the closure inside with() matters — pulling posts and its tags and its filtered comments in one pre-fetch pass.

Take this from ~120 queries on 30 authors to 4. That is the entire optimization, and it is invisible to the controller calling ->build().

The point is not the cleverness of any single line. It is that "fix N+1 once, never think about it again at this layer" is a feature of how you organise the data-loading edge of the codebase, not a one-time tweak.

Summary

N+1 is the most expensive mistake I see in Laravel projects. Eager loading covers ~80% of cases; preventLazyLoading() in tests catches the rest before they ship. The remaining 20% — top-N per group, deep nested relations, polymorphic edges — are where the Eloquent docs become required reading.