The N+1 query problem is the single biggest cause of slow Laravel apps. It's also the easiest perf issue to fix once you can see it. Here's how I find and eliminate them systematically.
The Pattern
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // 1 query per post
}
Loading 50 posts: 1 query for posts + 50 queries for authors = 51 queries. The "+1" is the initial collection load. Hence "N+1".
How To See Them
Laravel Debugbar (Local)
composer require barryvdh/laravel-debugbar --dev
Open any page; the bottom toolbar shows a query count and a list. If your homepage runs 200 queries, the count goes red. You'll know.
DB::listen (Anywhere)
DB::listen(function ($query) {
Log::channel('queries')->info($query->sql, [
'bindings' => $query->bindings,
'time_ms' => $query->time,
]);
});
Drop this in AppServiceProvider::boot() (gated to staging). Tail the log during a load test to see the volume.
Laravel Telescope (Best for Production-like Envs)
composer require laravel/telescope
php artisan telescope:install
Telescope groups queries by request. Sort by query count descending, look for the offenders.
Strict Mode (Best for Local Dev)
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
This makes Eloquent throw an exception when it would lazily load a relation. Forces you to declare with() upfront. Catches N+1 the moment you write it.
I run this in local + CI. Production stays in non-strict mode in case a missed eager load slips through (better to be slow than to throw a 500).
The Fix — Eager Loading
// Before — N+1
$posts = Post::all();
foreach ($posts as $post) { echo $post->author->name; }
// After — 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $post) { echo $post->author->name; }
Nested relations:
Post::with(['author', 'tags', 'comments.author'])->get();
Reads the entire object graph in 4 queries (posts, authors, tags, comments+commentAuthors) regardless of how many posts you have.
When Eager Loading Backfires
Eager loading 20 columns × 1,000 records when you only need 2 columns is wasteful. Constrain the eager load:
Post::with(['author:id,name'])->get();
The author relationship is loaded with only id and name. The leading id is required so Eloquent can match the foreign key.
Counts Without Loading
If you only need the count of a relation, use withCount:
Post::withCount('comments')->get();
// Each post now has $post->comments_count, no comments loaded
For complex aggregations:
Post::withCount(['comments as approved_comments_count' => fn ($q) => $q->where('approved', true)])
->get();
Polymorphic Relations — The Hidden N+1
$activities = Activity::all();
foreach ($activities as $activity) {
echo $activity->subject->title; // morphTo — N+1!
}
morphTo doesn't eager load by default because the target type varies per row. Force it:
$activities = Activity::with('subject')->get();
Eloquent groups by subject_type and runs one query per type. 500 activities across 3 types = 4 queries instead of 501.
Computed Properties That Hit The Database
class Post extends Model
{
public function getReadingTimeAttribute(): int
{
return $this->comments()->count() * 30; // ❌ runs on every access
}
}
This computes on every property read, hitting the database each time. Cache it:
public function getReadingTimeAttribute(): int
{
return $this->getOriginal('comments_count', $this->comments()->count()) * 30;
}
Or better, use withCount upstream and read it.
API Resources Are Sneaky
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'title' => $this->title,
'author' => $this->author->name, // N+1 if `author` not eager loaded
];
}
}
Resources iterate the collection silently. Always pre-load the relations the resource needs:
return PostResource::collection(Post::with('author', 'tags')->paginate());
Production Symptoms
If you don't have Telescope/Debugbar in production:
- DB CPU spikes on cache misses
- p95 latency much higher than p50 (some users hit cold caches and trigger N+1 cascades)
- Slow query log full of identical queries with different IDs
The slow query log fix:
-- Postgres
SELECT query, calls, mean_time
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
If a single query is in your top-20-by-calls list with 10,000 calls in an hour, that's an N+1.
What I Always Do
Model::preventLazyLoading()in local + CI- Eager load by default, constrain when measured
- Telescope in staging, sampled in production
- Add a query count assertion to feature tests for hot paths
That last one:
it('shows the homepage in fewer than 10 queries', function () {
DB::enableQueryLog();
get('/');
expect(DB::getQueryLog())->toHaveCount(lessThan(10));
});
Catches regressions before they ship.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
10 Eloquent Query Optimizations, in Order of Effectiveness
Newer →
Adding PHPStan to a WordPress Plugin (Without Losing Your Mind)
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.