Your Laravel List Page Is Firing 101 Queries: Fixing N+1 with Eager Loading
A Laravel list page that fires one query per row has the classic N+1 problem. Here's how Eloquent's with() fixes it, how to prove it in Tinker, and when eager loading is the wrong tool.
21 Aug 2025, 17:24 UTC

The list page that gets slower every week
You ship a page that lists posts with their comment counts and latest comments. It feels fine in development with ten rows. Three months later, with a few hundred posts, the page takes seconds to load and the database CPU graph has a suspicious spike on every request.
The cause is almost always the same: the N+1 query problem. You run one query to fetch the posts, then Eloquent silently runs one extra query per post to fetch its comments. One hundred posts means 101 queries. The takeaway: Laravel's with() method collapses that into two queries, and you can prove it yourself in Tinker in about a minute.
Why lazy loading hides the problem
Eloquent relationships are lazy by default. When you write this in a Blade template:
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2>
<p>{{ $post->comments->count() }} comments</p>
@endforeach...and the controller did Post::all(), then $post->comments triggers a fresh SELECT * FROM comments WHERE post_id = ? for every single post. Nothing in the code looks wrong, which is why this slips through code review. The queries only appear at runtime, one per loop iteration.
The fix: eager loading with with()
Eager loading tells Eloquent which relationships you will need before it runs the query. It then fetches the related models in a single second query using WHERE post_id IN (...) and matches them up in PHP:
// app/Http/Controllers/PostController.php
public function index()
{
$posts = Post::with('comments')->latest()->paginate(20);
return view('posts.index', compact('posts'));
}Now the whole page runs two queries regardless of how many posts are on it: one for the posts, one for all their comments. You can also eager load nested relations (with('comments.author')) and constrain what gets loaded:
Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();This works on any recent Laravel version (the syntax above is valid from Laravel 8 onward; older versions use a closure with function ($q) { ... } instead of the arrow function).
Prove it in Tinker
Don't take the framework's word for it. Laravel ships with Tinker, an interactive REPL. Run php artisan tinker from your project root (no special permissions beyond your normal app environment), then:
>>> DB::enableQueryLog();
>>> Post::with('comments')->get();
>>> count(DB::getQueryLog());
=> 2Compare with the lazy version: run DB::flushQueryLog(), then Post::all()->each(fn ($p) => $p->comments->count()), and check the log count again — it should be 1 plus the number of posts. For full requests rather than Tinker, Laravel Debugbar or Telescope shows the same query list per page load. Note: the query log holds every query in memory, so leave it off in production.
If you want a permanent guard, Laravel can throw an exception whenever a relation is lazy-loaded. In AppServiceProvider::boot():
Model::preventLazyLoading(! app()->isProduction());Now missing with() calls fail loudly in development instead of silently in production.
The trade-off: eager loading isn't free
Eager loading fetches all columns and all related rows up front, whether or not the view uses them. Two consequences:
- Memory: loading 10,000 posts each with 50 comments hydrates 510,000 model instances. For exports or batch jobs,
chunk()orcursor()is a better fit. - Wasted data: if the template only needs the comment count, loading full comment models is overkill. Use
Post::withCount('comments')instead — it adds acomments_countattribute via a subquery, with no extra hydration.withSum()andwithAvg()cover aggregates similarly.
The rule of thumb: eager load what the view actually renders, aggregate what it only counts, and constrain the rest.
What to do on Monday
Pick your slowest list endpoint, open Tinker or Debugbar, and count its queries. If the number scales with the row count, add the missing with() calls, re-measure, and then flip on preventLazyLoading() in non-production environments so the next N+1 gets caught by a failing test instead of a monitoring alert.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.