Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel N+1 Queries: How to Spot Them and Kill Them for Good

About Post

The page worked fine on your laptop. Twelve rows of test data, instant load. Then it reached production, the table grew, and the same page started to crawl. Nothing in the code changed. The data did.

That's the signature of the N+1 query problem, probably the most common performance bug in Laravel apps. It's not a Laravel flaw. It's the price of Eloquent being so pleasant to use: lazy loading makes relationships feel free, and they are not.

Let's look at how it happens, how to spot it before your users do, and how to make it hard to reintroduce.

The mistake

Here's an innocent-looking controller and Blade loop:

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

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

One query loads the 50 posts. Then, inside the loop, every $post->author triggers its own query, because the author wasn't loaded yet. That's 1 query for the list plus N queries for the authors: 51 queries for one page.

Each query is fast on its own, which is exactly why it hides so well. The cost is in the round trips, and it grows with every row you display.

The fix: eager loading with with()

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

Now Laravel runs two queries: one for the posts, and one like select * from users where id in (...) for all their authors at once. It then matches them up in memory. 51 queries become 2, and the number stays 2 whether you show 50 posts or 500.

Nested relations and only the columns you need

Real pages rarely stop at one level. Comments have authors, contracts have tenants and units. Dot notation handles that:

$posts = Post::with([
    'author:id,name',            // only these columns
    'comments.author:id,name',   // nested relation
    'comments' => fn ($q) => $q->latest()->limit(5),
])->get();

Two things to know here:

  • When you select specific columns, always include the id and any foreign key Laravel needs to match the records, or the relation silently comes back empty.
  • A limit() inside an eager-load constraint used to apply to the whole query rather than per parent. Since Laravel 11 it is applied per parent record, but if you maintain an older app, check before relying on it.

The sneaky version: counting

This one slips past a lot of code reviews:

{{ $post->comments->count() }} comments

It looks harmless, but it loads every comment for every post just to count them. Ask the database to count instead:

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

// in Blade
{{ $post->comments_count }} comments

withCount adds a subquery and gives you a comments_count attribute. There are siblings too: withSum, withAvg, withMax, withExists. On a page that lists invoices with "total paid", withSum('payments', 'amount') saves you from loading every payment row.

Rule of thumb: if you only need a number from a relation, never load the relation. Let SQL do the counting.

Where N+1 hides besides Blade

  • API Resources. A resource that does 'author' => $this->author->name fires a query per item. Use $this->whenLoaded('author') so the resource only includes what the controller eager loaded.
  • Accessors and $appends. An appended attribute that touches a relation runs for every model you serialise.
  • Queued jobs and loops in commands. No page is slow, so nobody notices, until the nightly job takes hours.
  • Policies. A policy that checks $user->roles inside a list can do it once per row.

If a model is already loaded and you need to add relations, use $posts->load('tags'), or loadMissing() to skip ones that are already there.

How to spot it

Laravel Debugbar (the barryvdh/laravel-debugbar package) shows the query count for every page in local development. Get used to glancing at it. If a list page shows dozens of near-identical queries that differ only by an ID, you've found one.

Laravel Telescope records requests, queries and jobs, which is useful for API endpoints and queued work where there's no page to put a toolbar on.

But tools you have to look at only catch problems when you look. The real win is making Laravel complain for you.

Kill it for good: preventLazyLoading

Add this to the boot() method of your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

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

Now, outside production, any lazy-loaded relation throws a LazyLoadingViolationException. The N+1 you just wrote fails loudly on your machine, in your tests, and in CI, instead of quietly in front of users.

Why not in production? Because a crash is worse than a slow page. If you want visibility there, you can log violations instead of throwing:

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    logger()->warning('Lazy loading detected', [
        'model'    => get_class($model),
        'relation' => $relation,
    ]);
});

If you want even stricter defaults, Model::shouldBeStrict() also turns on protection against silently discarded mass-assignment attributes and against accessing attributes that weren't selected.

One honest note: switching this on in an existing app can surface a lot of violations at once. That's not a reason to skip it. Fix them page by page; each one is a real query you're removing.

The checklist

  • Every list page and API collection uses with() for the relations it displays.
  • Counts and sums come from withCount / withSum, not from loaded collections.
  • API Resources use whenLoaded().
  • preventLazyLoading is on outside production.
  • You glance at the query count before opening a pull request.

The official eager loading docs cover a few more variations worth knowing, like morph relations.

Where did your worst N+1 hide: Blade, an API Resource, or somewhere stranger?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close