Learn how Eloquent’s N+1 query problem causes performance issues and how to fix it using eager loading for faster, more scalable applications.
Working with large codebases has taught me that database optimization isn’t a luxury, it’s a responsibility. When you’re managing applications that serve thousands of requests daily, every inefficient query compounds across the system, impacting user experience and infrastructure costs alike.
One of the best and worst things about Laravel’s Eloquent ORM is its “magic.” The ability to access related models with a simple arrow ($post->comments) is a beautiful abstraction. It’s clean, readable, and feels intuitive.
But as with any abstraction, there’s a cost. This particular magic has a hidden performance trap that’s all too easy to fall into, and it’s one of the first things I look for when diagnosing a slow application: the N+1 query problem.
This isn’t just a beginner’s mistake. It’s a scalability issue that can lie dormant in development and then bring a production server to its knees.
Working with large codebases has taught me that database optimization isn’t a luxury, it’s a responsibility. When you’re managing applications that serve thousands of requests daily, every inefficient query compounds across the system, impacting user experience and infrastructure costs alike.
One of the best and worst things about Laravel’s Eloquent ORM is its “magic.” The ability to access related models with a simple arrow ($post->comments) is a beautiful abstraction. It’s clean, readable, and feels intuitive.
But as with any abstraction, there’s a cost. This particular magic has a hidden performance trap that’s all too easy to fall into, and it’s one of the first things I look for when diagnosing a slow application: the N+1 query problem.
This isn’t just a beginner’s mistake. It’s a scalability issue that can lie dormant in development and then bring a production server to its knees.
The N+1 problem is insidious because the code looks perfectly fine. In fact, it’s often the most “obvious” way to write the code.
Let’s consider the classic blog example: a Post that hasMany(Comment::class).
If we need a page to show all posts and their comments, we might write this in our controller:
// app/Http/Controllers/PostController.php
public function index() {
$posts = Post::all();
return view('posts.index', ['posts' => $posts]);
}
And in our Blade view, we’d naturally loop like this:
// resources/views/posts/index.blade.php
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2>
<ul>
@foreach ($post->comments as $comment)
<li>{{ $comment->body }}</li>
@endforeach
</ul>
@endforeach
This code is clean, readable, and functionally correct. But it’s a performance disaster.
Here’s what just happened under the hood. If we have 100 posts:
SELECT * FROM postsSELECT * FROM comments WHERE post_id = 1SELECT * FROM comments WHERE post_id = 2SELECT * FROM comments WHERE post_id = 3Eloquent’s “magic” is lazy loading. It doesn’t fetch the comments until the moment we access $post->comments inside the loop. The result is 101 separate database queries for one page load.
In a professional context, we don’t just guess. When a page feels “slow,” our first job is to measure. My two essential tools for this are Laravel Telescope and Laravel Debugbar.
These tools don’t just tell you if it’s slow, they show you why.
Opening the “Queries” tab in Telescope for that page load makes the problem undeniable. You’ll see one query for posts and then a flood of identical SELECT * FROM comments queries. This is the “smoking gun” I look for. A high query count that scales with the amount of data on the page is a classic N+1 symptom.
The fix, in this case, isn’t a “hack.” It’s about shifting from implicit lazy loading to explicit eager loading. We need to be more deliberate about the data we need.
By making one small change to the controller, we can fundamentally change the execution profile.
// app/Http/Controllers/PostController.php
public function index()
{
// We explicitly tell Eloquent to fetch the 'comments' relation
// for all posts at the same time.
$posts = Post::with('comments')->get();
return view('posts.index', ['posts' => $posts]);
}
The Blade file doesn’t change at all. The interface is the same, but the implementation is now efficient.
Here’s the new query log:
SELECT * FROM postsSELECT * FROM comments WHERE post_id IN (1, 2, 3, 4, ...100)Eloquent is now smart enough to run one query to get all posts, collect their IDs, and then run a single second query to fetch all the comments for all those posts. It then stitches the relationships together in memory.
The difference is not trivial. We’re talking about a change that has a direct, measurable impact on server load and user experience.
Here’s a snapshot of a real-world scenario:
| Metric | Before (N+1) | After (Eager Loading) | % Improvement |
|---|---|---|---|
| DB Queries | 101 | 2 | ~98% |
| Page Load Time | 1,450ms | 80ms | ~94% |
| Memory Usage | 48 MB | 12 MB | ~75% |
This is the kind of optimization that matters. It’s the difference between an app that scales and an app that crumbles.
The N+1 problem doesn’t just appear in simple ::all() calls. It’s a pattern, and it’s critical to spot its variations.
1. The “Count” Trap
A common variation is when you just need a count of the related models.
<!-- THE TRAP! -->
<h2>{{ $post->title }} ({{ count($post->comments) }})</h2>
If you didn’t eager load, this is still an N+1. It will run a SELECT COUNT(*)... query for every single post.
The engineering-focused solution is to use withCount().
// Controller
$posts = Post::withCount('comments')->get();
// View
// Eloquent adds a {relation}_count property
<h2>{{ $post->title }} ({{ $post->comments_count }})</h2>
This is far more efficient, using a subquery to get all the counts in the initial posts query.
2. Constraining Eager Loads
Sometimes you don’t need all related models. Eloquent’s with() method elegantly accepts constraints, which keeps your data-fetching logic clean and centralized.
$posts = Post::with(['comments' => function ($query) {
$query->where('is_published', true);
}])->get();
Eloquent’s “magic” is a double-edged sword. It provides a beautiful, expressive API but assumes we, as engineers, understand the “cost” of our commands.
Lazy loading is a great default, but for any relationship that I know I’ll be accessing in a loop, eager loading with with() or withCount() is my non-negotiable standard practice.
The N+1 isn’t a “bug” in Laravel. It’s a classic ORM trade-off. Recognizing it and proactively solving it is a key part of moving from just “writing code” to “engineering a performant system.”
© Achour.dev 2025, All rights reserved.