Laravel N+1: with() vs load() vs withCount()
Maksudur Rahman
TL;DR: Calling relationships or chaining
->count()on Eloquent models inside a Blade loop triggers the infamous N+1 query bugβturning 1 innocent query into 501 sequential database calls. When you need related models, usewith()orload()to fetch them in 2 batch queries. When you only need the aggregate total, usewithCount()to push the calculation directly into MySQL as an indexed subqueryβyielding 1 single query, zero hydrated models, and a 150x speedup.
πΊ Watch the 40-Second Quick Breakdown
Prefer a fast visual overview? Watch our accompanying video breakdown on YouTube:
πWatch on YouTube Shorts: How to Fix Laravel N+1 Query Bug
The Silent Production Killer: A Familiar Scenario
Picture this scenario that almost every Laravel engineer has experienced:
It's Friday afternoon. You build a clean new blog feed or an e-commerce dashboard. In your local development environment, you seed 10 sample posts. You hit refresh in your browserβpage load time: 14 milliseconds. Everything feels lightning-fast and responsive. You submit your pull request, get it approved, and deploy to production.
Fast-forward to Tuesday morning. Production now has 500 published posts with several thousand user comments. Suddenly:
Page load times crawl to 2,400+ milliseconds.
PHP process memory spikes past 68 MB per request.
MySQL CPU utilization jumps to 95%+, choking unrelated background jobs and user requests.
Your code didn't change, your server wasn't DDoS'd, and your database hasn't run out of disk space.
You just fell straight into the classic Eloquent N+1 Query Trap.
1. The Code Trap: An Innocent-Looking Blade Loop
Let's look at the controller code that triggered the meltdown:
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\View\View;
class PostController extends Controller
{
public function index(): View
{
// Looks clean and harmless: 1 query to fetch 500 latest posts
$posts = Post::latest()->take(500)->get();
return view('posts.index', compact('posts'));
}
}At first glance, this is textbook Laravel. You fetch 500 records and pass them to your view.
Now, take a look at the Blade template:
{{-- resources/views/posts/index.blade.php --}}
<div class="space-y-4">
@foreach ($posts as $post)
<article class="p-4 bg-white rounded shadow">
<h2 class="text-xl font-bold">{{ $post->title }}</h2>
{{-- π¨ THE HIDDEN MISTAKE: Accessing relation count inside the loop --}}
<span class="text-sm text-gray-500">
{{ $post->comments->count() }} Comments
</span>
</article>
@endforeach
</div>Do you see the bug?
On line 10, the developer called$post->comments->count().
To a human reading the code, it feels like reading a property. But to Laravel Eloquent, that single line unleashes a storm of hidden SQL queries.
2. Under the Hood: Why Laravel Fires 501 Secret Queries
Eloquent uses Lazy Loading by default. When you initialize$posts = Post::latest()->take(500)->get(), Laravel executes exactly 1 query:
SELECT * FROM `posts` ORDER BY `created_at` DESC LIMIT 500;At this moment, the comments relationship has not been loaded into memory.
When PHP reaches the@foreach loop and evaluates$post->comments on post #1:
Eloquent inspects the internal
$relationsarray on the$postmodel.It discovers that
commentsis empty.It immediately fires an individual, blocking SQL query across your network:
SELECT * FROM `comments` WHERE `comments`.`post_id` = 1;Then the loop moves to post #2:
SELECT * FROM `comments` WHERE `comments`.`post_id` = 2;Then post #3, post #4... all the way to post #500:
SELECT * FROM `comments` WHERE `comments`.`post_id` = 500;The Math Behind N+1:
$$\text{Total Queries} = 1 \text{ (Initial Posts Fetch)} + N \text{ (500 Comment Queries)} = 501 \text{ Queries}$$
Instead of 1 query, your application just forced MySQL to parse, optimize, execute, and return 501 separate SQL statements over the network.
3. The Double Penalty: Network Overhead + The "Memory Bomb"
Most developers think the N+1 problem is purely about network latency. While 501 network roundtrips over TCP are certainly devastating, the second hidden cost is even worse: Model Hydration Overhead.
When you write$post->comments->count():
Eloquent runs
SELECT * FROM comments WHERE post_id = ?.It downloads every single column of every comment belonging to that post.
PHP creates a full
App\Models\Commentobject for every row.Each model allocates memory for attributes, original values, casts, event listeners, and mutation states.
Finally, PHP runs the collection method
count()on the array of hydrated objects.
If each of your 500 posts has an average of 20 comments:
PHP just hydrated 10,000 Eloquent model instances into RAM!
You wasted 68+ megabytes of memory and millions of CPU cycles just to display a simple number badge like
"20 Comments".
4. The 3 Weapons: with() vs load() vs withCount()
Laravel provides three purpose-built tools to solve this problem. Choosing the right one depends entirely on what your template actually needs.
What does your view need?
β
βββββββββββββββββββββββββ΄ββββββββββββββββββββββββ
βΌ βΌ
Only the count / total? Need model properties?
(e.g., "12 Comments") (e.g., Author, Body, Date)
β β
βΌ β
ββββββββββββββββββββ β
β withCount() β β
β (1 SQL Query) β β
ββββββββββββββββββββ β
βΌ
Is the collection already loaded?
β
ββββββββββββββββββ΄βββββββββββββββββ
βΌ βΌ
[ NO ] [ YES ]
β β
βΌ βΌ
ββββββββββββββββββββ ββββββββββββββββββββ
β with() β β load() β
β (Eager Loading) β β(Lazy-Eager Load) β
ββββββββββββββββββββ ββββββββββββββββββββSolution 1: Eager Loading withwith()
Use with() when your view actually needs to loop through the child models and display their fields (such as comment author names or comment bodies):
// app/Http/Controllers/PostController.php
public function index(): View
{
// Eager loads the comments relationship upfront
$posts = Post::with('comments')->latest()->take(500)->get();
return view('posts.index', compact('posts'));
}What Laravel Does Under the Hood:
Instead of 501 queries, Laravel executes exactly 2 queries:
-- Query 1: Fetch 500 posts
SELECT * FROM `posts` ORDER BY `created_at` DESC LIMIT 500;
-- Query 2: Fetch all comments for all 500 posts in ONE batch
SELECT * FROM `comments` WHERE `comments`.`post_id` IN (1, 2, 3, ..., 500);Laravel then automatically matches the comments to their respective parent posts in memory.
Solution 2: Lazy-Eager Loading withload()
Sometimes you don't know upfront whether you need a relationship, or you are working with an existing model collection that was passed into a service layer or conditional block.
In this case, use load():
public function showFeed(Request $request): View
{
$posts = Post::latest()->take(500)->get();
// Conditionally eager-load relations onto an existing collection
if ($request->boolean('include_comments')) {
$posts->load('comments');
}
return view('posts.index', compact('posts'));
}load() works identically to with(), but it runs after the parent collection has already been fetched from the database.
Solution 3: The Gold Standard for Totals βwithCount()
Here is the key takeaway: If your view only needs to display the number of comments, never use with() or load()!
Use withCount():
// app/Http/Controllers/PostController.php
public function index(): View
{
// Tells Eloquent to calculate the count directly in SQL
$posts = Post::withCount('comments')->latest()->take(500)->get();
return view('posts.index', compact('posts'));
}Now in your Blade template, access the generated{relation}_count attribute:
{{-- Clean, lightning-fast, and zero hydration --}}
<span class="text-sm text-gray-500">
{{ $post->comments_count }} Comments
</span>WhywithCount()is a Performance Superpower:
Look at the single SQL statement generated by Laravel:
SELECT
`posts`.*,
(
SELECT COUNT(*)
FROM `comments`
WHERE `posts`.`id` = `comments`.`post_id`
) AS `comments_count`
FROM `posts`
ORDER BY `created_at` DESC
LIMIT 500;Notice what happens:
Query Count: Exactly 1 query.
Model Hydration:0 comment models hydrated into PHP memory.
Execution: Database storage engines (like InnoDB) calculate counts using primary and foreign key indexes in optimized C++, returning only an integer back to PHP.
5. Benchmark Comparison: Real Numbers
We ran a controlled benchmark on MySQL 8.0 with PHP 8.3 handling 500 posts and 10,000 comments. Here is how the three approaches stack up:
Benchmark Metric | Unoptimized (Lazy Loop) | Eager Load with | Subquery with |
|---|---|---|---|
Total SQL Queries |
|
|
|
Response Time |
|
|
|
PHP Memory Allocated |
|
|
|
Network Roundtrips | 501 individual roundtrips | 2 roundtrips | 1 single roundtrip |
Database Load | Severe connection exhaustion | Moderate buffer usage | Negligible index lookup |
The difference is night and day: moving from an unoptimized loop to withCount() resulted in a 150x speed increase and slashed RAM usage by 94%.
6. Production Guardrails: Catch N+1 Bugs Before Deployment
Never rely on memory or manual code reviews to catch N+1 bugs. Use these battle-tested safeguards:
1. Enable Strict Mode in Development
Add this single line to your AppServiceProvider.php. It will cause Laravel to throw an immediate runtime exception if any code attempts to lazy-load a relationship in local or testing environments:
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// π‘οΈ Throws an exception in local/staging if lazy loading is attempted
Model::preventLazyLoading(! $this->app->isProduction());
}
}Now, the moment you accidentally call$post->comments without eager loading on your laptop, Laravel throws a LazyLoadingViolationException, preventing the bug from ever reaching production.
2. Counting with Query Constraints
You can filter counts without fetching models. For example, if you only want to count approved comments:
$posts = Post::withCount([
'comments' => fn ($query) => $query->where('is_approved', true)
])->get();
// Access via: $post->comments_count3. Aliasing Multiple Counts
Need counts for multiple statuses on the same relationship? Alias them in one call:
$posts = Post::withCount([
'comments as total_comments',
'comments as pending_comments' => fn ($q) => $q->where('status', 'pending'),
'comments as approved_comments' => fn ($q) => $q->where('status', 'approved'),
])->get();
// Blade: {{ $post->pending_comments }} / {{ $post->total_comments }}Frequently Asked Questions (FAQ)
What is the difference between$post->comments->count()and$post->comments()->count()?
$post->comments->count()(property access) triggers lazy loading: it queries all rows, loads fullCommentmodels into PHP memory, and runs PHP'scount()on the collection.$post->comments()->count()(method call) returns a query builder instance and firesSELECT COUNT(*) FROM comments WHERE post_id = ?. While this avoids model hydration, calling it inside a loop still causes 500 individual queries. UsewithCount()to solve both issues.
Can I select only specific columns when eager loading withwith()?
Yes! To save memory, always select only the columns you need, ensuring you include the foreign key:
$posts = Post::with('comments:id,post_id,body,created_at')->get();Can I usewithCount()on polymorphic relationships?
Yes. Laravel's withCount() natively supports hasMany, belongsToMany, and polymorphic relations like morphMany without additional configuration.
Summary & What's Next
Writing performant Laravel code isn't about avoiding Eloquent or writing raw SQLβit's about understanding how Eloquent translates your object calls into database queries under the hood.
Here is your golden rulebook:
Need child records?
with()Need child records conditionally?
load()Only need the number of records?
withCount()Want peace of mind?
Model::preventLazyLoading(! app()->isProduction())
Follow@kodersolution for more high-performance backend architecture breakdowns and subscribe to our YouTube channel.