Skip to content

Hunting N+1 Queries in Laravel: Tools, Strict Mode, and Fixes

A

Al Amin Ahamed

Senior Engineer

8 min read
𝕏 in

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.

Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.