Skip to content
How to find N+1 queries with Telescope, Debugbar, and preventLazyLoading() — and the eager-loading patterns that fix the...

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

Al Amin Ahamed

Al Amin Ahamed

Senior Software Engineer

· Updated 7 hours ago 8 min read

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

PHP
$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)

BASH
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)

PHP
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)

BASH
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)

PHP
// 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

PHP
// 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:

PHP
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:

PHP
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:

PHP
Post::withCount('comments')->get();
// Each post now has $post->comments_count, no comments loaded

For complex aggregations:

PHP
Post::withCount(['comments as approved_comments_count' => fn ($q) => $q->where('approved', true)])
    ->get();

Polymorphic Relations — The Hidden N+1

PHP
$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:

PHP
$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

PHP
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:

PHP
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

PHP
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:

PHP
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:

SQL
-- 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:

PHP
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
Al Amin Ahamed

Al Amin Ahamed

Senior software engineer & AI practitioner. 5+ years shipping Laravel platforms, WordPress plugins, WooCommerce extensions, and AI-driven products.

About me →

More from the blog

Need this kind of work shipped?

Available for freelance and consulting.

Laravel platforms, WordPress plugins, WooCommerce extensions, and AI integrations.