aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Web Development

Laravel N+1 Query Problem and How to Fix It

The Laravel N+1 query problem is a sneaky performance issue that nearly every Eloquent-powered project runs into sooner or later. The code looks clean, the tests pass, the page loads fast on your machine — then a few thousand rows pile up in the database and that same page suddenly crawls. The cause is usually one single thing: firing dozens or even hundreds of extra queries inside a loop without realizing it. In this article I'll walk through why it happens, how to catch the hidden queries with the query log, and how to fix it for good with eager loading.

What exactly is the N+1 problem?

The name says it all. One parent query (1) runs and returns N records; then, for each of those N records, another query (N) runs to fetch its related data. That's 1 + N queries in total. If you list 20 posts and show each post's author, 1 query goes out for the posts and 20 go out for the authors: 21 queries.

Let's look at the classic example. On a blog listing we want to show each post's author name:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name;
}

The first line produces a single select * from posts query. But the $post->author expression inside the loop triggers a separate select * from users where id = ? query for every post. Eloquent loads the relationship with lazy loading — that is, the moment it's first accessed. So 100 posts means 101 queries, and you'd never notice it just by looking at the output.

Catching the hidden queries with the query log

The first step to fixing the problem is seeing it. Laravel's DB facade can record every query that runs. You can temporarily enable it inside a route or controller like this:

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$posts = Post::all();
foreach ($posts as $post) {
    $post->author->name;
}

dd(DB::getQueryLog());

If you see 101 entries in the output, the culprit is caught. The same SQL repeating over and over with where id = ? is the clearest signature of N+1.

A more permanent approach is to log every query inside a service provider:

DB::listen(function ($query) {
    logger()->info($query->sql, $query->bindings);
});

Drop this into the boot method of AppServiceProvider and watch storage/logs/laravel.log to see exactly how many queries a single request runs. In development, tools like Laravel Debugbar or Telescope also show this count automatically at the bottom of each page; instead of scanning repeated queries by eye, you simply notice the counter climbing.

The fix: eager loading with with()

The solution is surprisingly simple. Instead of loading the relationship one row at a time in the loop, you tell Eloquent up front to "fetch this relationship too" while the parent query runs. You do that with with():

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

foreach ($posts as $post) {
    echo $post->author->name;
}

Now Eloquent runs only two queries: one for the posts, and one that fetches all the authors at once with select * from users where id in (1, 2, 3, ...). Two queries instead of 101 for 100 posts. That's how N+1 turns into 1 + 1.

You can also load multiple relationships and nested ones at the same time:

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

Here the comments.user dot syntax loads each comment's user as well, so iterating over comments won't create a new N+1. If you want to lighten the query by selecting only specific columns:

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

Careful: in the column subset you must always include the relationship's foreign key — id here — otherwise Eloquent can't match the records and the relationship comes back null.

Other useful techniques

  • loadMissing(): If you already have the models and aren't sure whether the relationship is loaded, this fills in the gaps without firing redundant queries: $posts->loadMissing('author').
  • withCount(): If you only show the number of related records, get the count in a single query instead of fetching all the rows: Post::withCount('comments')->get(). The result arrives as $post->comments_count.
  • Automatic protection: Laravel lets you ban lazy loading entirely. Add Model::preventLazyLoading(! app()->isProduction()) in AppServiceProvider and accessing a relationship that wasn't eager loaded throws an exception in development. That way you catch N+1 while you're still writing the code.

Eager loading is powerful, but don't develop a reflex to slap with() on everything. If you don't actually use a relationship on the page, loading it backfires and bloats memory. The rule is simple: every relationship accessed inside a loop should be eager loaded; no relationship that's never accessed should be.

Frequently Asked Questions

Is the N+1 problem always a real problem?

On small datasets it's unnoticeable; 6 queries for 5 records bothers no one. But as data grows, the query count climbs linearly and hundreds of queries choke both the database and the response time. The worst part is that it's invisible locally and blows up in production — which is why measuring early is the right approach.

What's the difference between with() and load()?

with() plans the relationship while the query is being built, before the models are fetched. load() adds a relationship afterward to a collection you already hold. Both use the same batched-query logic; which one you pick depends on when you obtained the models.

How do I make eager loading mandatory in testing?

Enable Model::preventLazyLoading() only outside production. That way, during testing and development every relationship you forget to eager load throws an exception, while in production it quietly behaves as normal.

Is your Laravel app slower than expected? The culprit is often hidden N+1 queries. We can profile your project together and bring the query count down — get in touch with me.

Bu kategorideki tüm yazılar →

Devamı için