The Laravel N+1 query problem is the most common reason a page that was fast with ten rows becomes slow with a thousand. It happens when you load a list of models and then touch a relationship on each one inside a loop, so Eloquent runs one query for the list plus one more query per row. In a test with 50 posts, a simple loop that prints each post's author ran 51 queries. The same page with one extra word in the query ran 2. This post shows how to spot the problem, the fixes that matter, and how to make Laravel complain when someone introduces it again.
What it looks like
// A Post belongs to an Author, and has many Comments.
foreach (Post::all() as $post) {
echo $post->author->name; // lazy loads the author, one query per post
}
This is the example from the Laravel documentation: one query for the posts, then another for each post to fetch its author. Nothing looks wrong in the code, which is why it ships so easily.
Count your queries first
Do not guess. Count the queries for the code path you care about:
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
// ... run the code you want to measure ...
dump(count(DB::getQueryLog()));
Debug tools such as Laravel Debugbar or Telescope show the same information per request. If the count grows with the number of rows, you have an N+1.
Fix 1: eager load with with()
foreach (Post::with('author')->get() as $post) {
echo $post->author->name;
}
Eloquent now runs two queries, regardless of how many posts there are:
select * from "posts"
select * from "authors" where "authors"."id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
To pull only the columns you need, name them after a colon, and always include the key that links the models: Post::with('author:id,name')->get() produces select "id", "name" from "authors" ....
Fix 2: use withCount() when you only need a number
Calling $post->comments->count() in a loop loads every comment just to count it, and does it once per post (51 queries in my test). If you only need the count, let the database do it:
foreach (Post::withCount('comments')->get() as $post) {
echo $post->comments_count;
}
select "posts".*, (select count(*) from "comments" where "posts"."id" = "comments"."post_id") as "comments_count" from "posts"
One query, and no comment rows are loaded at all.
Fix 3: load relations on a collection you already have
Sometimes the models come from somewhere you do not control, such as a paginator or a method that returns a collection. Eager load afterwards:
$posts = Post::all();
$posts->load('author'); // 2 queries in total
$posts->loadMissing('author'); // skips relations that are already loaded
Fix 4: automatic eager loading (beta)
Laravel can also eager load for you when a relationship is first accessed on one of the models in a result set. Turn it on in AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
With it enabled, the original lazy loop that cost 51 queries ran in 2, and the loop that read both the author and the comments of each post ran in 3. The Laravel 12.x documentation marks this feature as beta, saying its behavior may change even in patch releases, so check the current documentation before enabling it globally. Explicit with() is still the clearer choice for code you want to read at a glance.
Measured results
| Code (50 posts) | Queries |
|---|---|
Lazy: $post->author->name
|
51 |
Post::with('author') |
2 |
| Lazy: author and comments in the loop | 101 |
Post::with(['author', 'comments']) |
3 |
Lazy: $post->comments->count()
|
51 |
Post::withCount('comments') |
1 |
Lazy loop with automaticallyEagerLoadRelationships()
|
2 |
Make Laravel catch it for you
Fixing one N+1 by hand does nothing about the next one. Prevent lazy loading in development so it fails loudly:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
Now the lazy loop throws:
Illuminate\Database\LazyLoadingViolationException: Attempted to lazy load [author] on model [Post] but lazy loading is disabled.
Two details I confirmed by running it. First, the exception only fires for models that came from a result with several models; fetching one model with Post::first() and reading $post->author did not throw. Second, with() keeps working as normal, so only the accidental lazy loads are flagged.
Leaving the guard off in production is deliberate: a missed eager load there costs speed, but an exception costs the request. If you want to see violations in production without breaking pages, log them instead of throwing:
use Illuminate\Database\Eloquent\Model;
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
logger()->warning("Lazy loaded [{$relation}] on [".$model::class.']');
});
Note the method name is handleLazyLoadingViolationUsing, singular "Violation". The prose in the Laravel documentation spells it differently from its own code example, and the source uses the singular form. Model::shouldBeStrict() turns on lazy-loading prevention together with two other strictness checks, for silently discarded attributes and for accessing missing attributes, if you want all three.
Common mistakes
-
Eager loading what you never use.
with()loads every related row. If you only need a count, usewithCount(); if you only need two columns, select them. -
Forgetting nested relations. A loop that reads
$post->commentsand then$comment->userneedswith('comments.user'), or you have moved the N+1 one level down. - Checking only the first page. With ten test rows, 11 queries feels fine. Seed realistic data or assert on query counts in a test.
Before you ship it
Count the queries on any list page, with realistic data. Replace per-row relationship access with with() or withCount(). Add Model::preventLazyLoading(! $this->app->isProduction()) to your service provider so new N+1s fail in development and in the test suite.
References: Laravel: Eager Loading, Laravel: Preventing Lazy Loading, and Laravel: Automatic Eager Loading.
Originally published on DEV Talk.
Top comments (0)