The N+1 Problem in Laravel: How I Find and Fix It
I once inherited a Laravel codebase making 847 database queries per page load. Not an exaggeration — I had Debugbar open and counted them. The page took 12 seconds to load. The developer who built it had no idea it was a problem because it felt "fine" on their local machine with 50 rows of test data.
The N+1 query problem is the most common performance issue I see in Laravel code reviews. Here's how I find it and fix it.
What N+1 Actually Means
You load a list of 20 posts (1 query). Then for each post, you access $post->author in your template. That's 20 more queries — one per post. So instead of 2 queries (posts + all their authors), you make 21. With 100 posts it's 101 queries. That's the N+1 pattern.
// This looks innocent but isn't
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // One query PER iteration
}
// This is 2 queries total, regardless of how many posts
$posts = Post::with('author')->get();
Step 1: Make N+1 Throw an Exception
The best thing you can do right now is add this to your AppServiceProvider:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
In development, any lazy loaded relationship now throws a LazyLoadingViolationException. You can't miss it. This alone has stopped me from shipping N+1 bugs more times than I can count.
Nested Relationships
Eager loading works at multiple levels. Load everything you need upfront:
// Load posts with authors, and each author's company
Post::with(['author.company', 'tags', 'comments' => function ($q) {
$q->latest()->limit(3);
}])->paginate(15);
withCount() Instead of Counting Relations
This catches a lot of people:
// Bad — loads all comments just to count them
$posts = Post::with('comments')->get();
$posts->first()->comments->count(); // Already loaded all comment data
// Good — single aggregated query, no comment data transferred
$posts = Post::withCount('comments')->get();
$posts->first()->comments_count; // Just the integer
Subquery Selects for Single Values
Need just the latest comment date per post? Loading the whole comments relationship wastes memory. Use a subquery:
Post::addSelect([
'latest_comment_at' => Comment::select('created_at')
->whereColumn('post_id', 'posts.id')
->latest()
->limit(1),
])->get();
This is one SQL query and is far more memory-efficient than eager loading the relationship.
Select Only What You Need
By default, Eloquent does SELECT *. On tables with text columns or many fields, this transfers a lot of unnecessary data. Be explicit:
Post::with(['author:id,name,avatar_url'])
->select(['id', 'title', 'slug', 'published_at', 'author_id'])
->published()
->paginate(20);
The id in the relationship select is required — Eloquent needs it to match relationships.
Caching for Repeated Expensive Queries
$categories = Cache::remember('post-categories', 3600, function () {
return Category::withCount('publishedPosts')
->orderBy('name')
->get();
});
// When a category changes, invalidate specifically
Cache::forget('post-categories');
Database Indexes: The Cheap Win
Indexes don't fix N+1 queries but they do fix slow queries at scale. If you're filtering or ordering by a column frequently, it needs an index. I always add these in the migration:
$table->index(['user_id', 'is_published', 'published_at']);
$table->unique('slug');
$table->index('status');
Run EXPLAIN on your slowest queries. type: ALL means a full table scan — that's an index missing.
Finding N+1 in Production
Log slow queries in production using Laravel's built-in hook:
DB::whenQueryingForLongerThan(500, function (Connection $connection) {
Log::channel('slack')->warning('Slow query detected', [
'url' => request()->fullUrl(),
'sql' => $connection->getQueryLog(),
]);
});
This gives you real production data about which pages are slow and why. Much better than guessing.