The N+1 query problem: how to catch it before production does
Every Laravel developer has shipped an N+1 query at least once. It's one of the few bugs that's genuinely invisible until it isn't — your local environment has a handful of seeded rows, the page loads instantly, tests pass, and you move on. Then a real dataset shows up, and a page that used to take 40ms takes four seconds.
ON THIS PAGE
What's actually happening
Here's the classic setup: a page listing posts along with each post's author.
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
This looks completely reasonable. It's also one query to fetch the posts, plus one additional query per post to fetch that post's author. Ten posts means eleven queries. A thousand posts means 1,001 queries. That's the "N+1" — one query, plus N more.
Eloquent doesn't warn you about this by default, because from Eloquent's perspective nothing is wrong. $post->author is a lazy-loaded relationship, and lazy loading is working exactly as designed: it fetches the related model the moment you access it, not before.
Why it's easy to miss
A few reasons this bug survives code review so often:
- Local data is small. Eleven queries and 1,001 queries both feel instant against a local database with no real latency. The bug only becomes visible at scale — which usually means production.
- The code reads correctly.
$post->author->nameis exactly what you'd write whether the relationship was eager loaded or not. There's no syntactic tell. - It compounds with nesting.
$post->author->company->namedoesn't turn one extra query into two — it can turn into N queries for authors and another N for companies, depending on how the relationships resolve.
Catching it in development
The fix starts with actually seeing your query count, which most people never look at until something's already slow.
Laravel Debugbar is the fastest way to get visibility during local development — it shows every query fired per request, including duplicates, right in a bar at the bottom of the page:
composer require barryvdh/laravel-debugbar --dev
Laravel Telescope does the same job with more detail and a searchable history, which is useful when you're chasing down a slow request that happened five minutes ago rather than reproducing it live.
Strict mode is the most reliable option, because it doesn't depend on you remembering to look. Add this to a service provider's boot() method, guarded so it only runs outside production:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
With this enabled, accessing an un-eager-loaded relationship throws a LazyLoadingViolationException in local and staging environments instead of silently firing an extra query. It turns an invisible performance bug into a loud, immediate error during development — which is exactly where you want to find it.
Fixing it: eager loading
The fix for the original example is with():
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
This runs two queries total, regardless of how many posts there are — one for posts, one for all their authors in a single WHERE IN clause.
Nested relationships eager load the same way, using dot notation:
$posts = Post::with('author.company')->get();
Constrained eager loading lets you filter or select specific columns on the related model, which matters once you're loading anything nontrivial:
$posts = Post::with(['author' => function ($query) {
$query->select('id', 'name');
}])->get();
If you already have the collection and realize partway through that you need a relationship, load() eager loads onto an existing collection instead of re-querying from scratch:
$posts = Post::all();
// ...later, once you know you need it:
$posts->load('author');
The case where you don't want the relationship at all
Sometimes you don't need the related models — you just need a count. Loading the full relationship for that is wasteful in the other direction. withCount() gets you the number without hydrating a single related model:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
Large datasets: don't all() your way into a memory problem
N+1 is a query-count problem. There's a related but distinct issue with large datasets: loading everything into memory at once. Post::all() on a table with a million rows will try to hydrate a million Eloquent models simultaneously.
chunk() processes records in batches:
Post::with('author')->chunk(200, function ($posts) {
foreach ($posts as $post) {
// process each post
}
});
cursor() (or lazy() in more recent Laravel versions) uses a PHP generator to fetch rows one at a time, which keeps memory flat even over huge tables — though note that cursor() doesn't work with eager loading via with() the same way, so check current docs for your version if you're combining the two.
A short checklist before shipping a listing page
- Have you actually looked at the query count for this endpoint, not just how it feels locally?
- Is
Model::preventLazyLoading()enabled outside production, so violations get caught automatically? - For every relationship accessed in a loop, is it in a
with()call? - If you only need a count, are you using
withCount()instead of loading the full relationship? - If the dataset could realistically grow past a few thousand rows, are you chunking or using a cursor instead of
all()?