DEV Community

Cover image for N+1 Query Detection and Prevention in Laravel Production Apps
Deploynix
Deploynix

Posted on • Originally published at deploynix.io

N+1 Query Detection and Prevention in Laravel Production Apps

The N+1 query problem is the most common performance issue in Laravel applications, and it's the easiest to introduce accidentally. A developer adds $post->author->name in a Blade template, and suddenly a page that loaded 10 posts now executes 11 database queries instead of 2. Scale that to 100 posts and you have 101 queries. Scale to a thousand and your database server is on fire.

What makes N+1 problems particularly insidious is that they're invisible during development. Your local database with 20 records responds instantly whether you execute 1 query or 100. The problem only becomes apparent in production where tables have millions of rows, query latency is higher, and database connections are shared across hundreds of concurrent requests.

This post covers everything you need to detect N+1 queries, prevent them from being introduced, and monitor for them in production Laravel applications.

Understanding the N+1 Problem

An N+1 query occurs when you load a collection of N models and then access a relationship on each model individually, triggering a separate query for each one.

Here's the classic example:

// Controller
$posts = Post::all(); // 1 query: SELECT * FROM posts

// Blade template
@foreach($posts as $post)
    {{ $post->author->name }} // N queries: SELECT * FROM users WHERE id = ?
@endforeach
Enter fullscreen mode Exit fullscreen mode

The first query loads all posts. Then, for each post, Laravel lazy-loads the author relationship with a separate query. With 50 posts, that's 51 queries total.

The fix is eager loading — tell Laravel to load all authors in a single query upfront:

$posts = Post::with('author')->get(); // 2 queries total
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
Enter fullscreen mode Exit fullscreen mode

Two queries instead of 51. The concept is simple. The challenge is applying it consistently across a growing codebase where relationships are accessed in controllers, views, components, API resources, and queued jobs.

Prevention: Laravel's preventLazyLoading

Laravel provides a built-in mechanism to catch N+1 problems during development. When enabled, any lazy-loaded relationship throws an exception:

// In a service provider's boot method
use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()->isProduction());
Enter fullscreen mode Exit fullscreen mode

Now the $post->author->name call without eager loading throws a LazyLoadingViolationException instead of silently executing an extra query. This forces developers to explicitly eager-load every relationship they access.

Handling Violations in Production

You don't want exceptions crashing pages in production. Instead, log violations so you can fix them without disrupting users:

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    logger()->warning("N+1 detected: lazy loading [{$relation}] on [" . get_class($model) . "]");
});
Enter fullscreen mode Exit fullscreen mode

This approach gives you the best of both worlds: hard failures in development that force immediate fixes, and silent logging in production that reveals problems you missed.

When preventLazyLoading Gets in the Way

There are legitimate cases where lazy loading is acceptable:

  • Artisan commands that process a single model and access one relationship
  • Queue jobs that operate on a known small dataset
  • Tinker sessions during debugging

You can selectively allow lazy loading on specific models:

// On a specific model
class Setting extends Model
{
    // Settings table is tiny; lazy loading is fine
    public $preventLazyLoading = false;
}
Enter fullscreen mode Exit fullscreen mode

Or disable it temporarily in specific contexts:

Model::withoutPreventing(function () {
    // Lazy loading is allowed in this closure
    $user->profile->bio;
});
Enter fullscreen mode Exit fullscreen mode

Eager Loading Patterns

Basic Eager Loading

The with() method accepts a single relationship or an array:

$posts = Post::with(['author', 'tags', 'comments'])->get();
Enter fullscreen mode Exit fullscreen mode

Nested Eager Loading

Load relationships of relationships using dot notation:

$posts = Post::with(['comments.author', 'author.profile'])->get();
// Loads: posts, comments, comment authors, post authors, author profiles
Enter fullscreen mode Exit fullscreen mode

Constrained Eager Loading

Filter or limit the eager-loaded relationship:

$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
          ->latest()
          ->limit(5);
}])->get();
Enter fullscreen mode Exit fullscreen mode

Laravel 12 supports limit() on eager loads natively — no external packages needed.

Conditional Eager Loading

Load relationships only when certain conditions are met:

$posts = Post::query()
    ->when($includeComments, fn ($query) => $query->with('comments'))
    ->when($includeAuthor, fn ($query) => $query->with('author'))
    ->get();
Enter fullscreen mode Exit fullscreen mode

Eager Loading on Existing Collections

If you already have a collection and need to load a relationship after the fact:

$posts = Post::all();

// Later, you realize you need authors
$posts->load('author');

// Or load only if not already loaded
$posts->loadMissing('author');
Enter fullscreen mode Exit fullscreen mode

loadMissing() is particularly useful in deep call stacks where you're not sure if a relationship was already eager-loaded upstream.

Default Eager Loading on Models

If a relationship is always needed when a model is loaded, define it as a default:

class Post extends Model
{
    protected $with = ['author'];
}
Enter fullscreen mode Exit fullscreen mode

Use this sparingly. It means every Post::all(), Post::find(), and Post::where(...) call loads the author relationship, even when it's not needed. This can turn a simple count query into a heavy join operation.

A better approach is to define scopes for common access patterns:

class Post extends Model
{
    public function scopeWithFeedRelations(Builder $query): Builder
    {
        return $query->with(['author', 'tags', 'comments' => function ($query) {
            $query->latest()->limit(3);
        }]);
    }
}

// Usage
$posts = Post::withFeedRelations()->latest()->paginate(20);
Enter fullscreen mode Exit fullscreen mode

Detecting N+1 in Existing Code

Query Counting in Tests

Write tests that enforce query budgets:

it('loads the post index without N+1 queries', function () {
    $author = User::factory()->create();
    Post::factory()->count(20)->for($author, 'author')->create();

    $queryCount = 0;
    DB::listen(function () use (&$queryCount) {
        $queryCount++;
    });

    $this->actingAs($author)
        ->get('/posts')
        ->assertOk();

    expect($queryCount)->toBeLessThan(10);
});
Enter fullscreen mode Exit fullscreen mode

If loading 20 posts requires more than 10 queries, something is lazy loading when it shouldn't be. This test catches regressions when someone adds a new relationship access in the view without updating the controller's eager loading.

Using Laravel Debugbar

Debugbar shows duplicate queries in its queries tab. If you see SELECT * FROM users WHERE id = ? repeated 20 times with different IDs, that's an N+1 problem on the author relationship.

Debugbar also shows the file and line where each query was triggered. This makes it trivial to trace the lazy load back to the specific Blade template line or component that caused it.

Using Telescope's Query Tab

Telescope groups queries by request. Look for requests with high query counts — anything above 20-30 queries per request warrants investigation. Sort by query count to find the worst offenders first.

Common N+1 Traps

Trap 1: Blade Components That Access Relationships

// app/View/Components/PostCard.php
public function render(): View
{
    return view('components.post-card');
}

// components/post-card.blade.php



## {{ $post->title }}

    by {{ $post->author->name }}  {{-- N+1 if author not eager-loaded --}}

Enter fullscreen mode Exit fullscreen mode

The component looks harmless. The N+1 is hidden because the relationship access happens in the view, far from the controller that loaded the posts.

Fix: Ensure the parent view or controller eager-loads the relationship:

$posts = Post::with('author')->paginate(20);
Enter fullscreen mode Exit fullscreen mode

Trap 2: API Resources with Nested Relationships

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => new UserResource($this->author), // N+1
            'comments_count' => $this->comments->count(), // N+1 AND loads all comments
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Fix: Eager-load in the controller and use withCount for counts:

$posts = Post::with('author')->withCount('comments')->paginate(20);

// In the resource:
'comments_count' => $this->comments_count, // Uses the aggregate, no extra query
Enter fullscreen mode Exit fullscreen mode

Trap 3: Accessors That Touch Relationships

class Order extends Model
{
    protected function totalWithTax(): Attribute
    {
        return Attribute::get(fn () => $this->total * (1 + $this->taxRate->rate));
        // Accessing $this->taxRate triggers a lazy load
    }
}
Enter fullscreen mode Exit fullscreen mode

Accessors are called during serialization, in Blade templates, and in API resources. Each call triggers the relationship query.

Fix: Eager-load taxRate wherever you use this accessor, or cache the relationship:

protected function totalWithTax(): Attribute
{
    return Attribute::get(function () {
        $rate = $this->relationLoaded('taxRate')
            ? $this->taxRate
            : $this->taxRate()->first();

        return $this->total * (1 + $rate->rate);
    });
}
Enter fullscreen mode Exit fullscreen mode

Trap 4: Queued Jobs Processing Collections

class SendWeeklyDigest implements ShouldQueue
{
    public function handle(): void
    {
        User::where('digest_enabled', true)->chunk(100, function ($users) {
            foreach ($users as $user) {
                // Each of these triggers a query
                $recentPosts = $user->subscriptions->flatMap->posts;
                Mail::to($user)->send(new WeeklyDigest($recentPosts));
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Fix:

User::where('digest_enabled', true)
    ->with(['subscriptions.posts' => function ($query) {
        $query->where('created_at', '>', now()->subWeek());
    }])
    ->chunk(100, function ($users) {
        foreach ($users as $user) {
            $recentPosts = $user->subscriptions->flatMap->posts;
            Mail::to($user)->send(new WeeklyDigest($recentPosts));
        }
    });
Enter fullscreen mode Exit fullscreen mode

Monitoring in Production

Even with preventLazyLoading and test coverage, N+1 problems can slip through. Monitor query counts in production using multiple layers.

Laravel Pulse

Pulse tracks slow queries and high-frequency queries in production. A query that appears hundreds of times per minute with slight parameter variations is likely an N+1 problem. Check Pulse's slow queries dashboard regularly.

Deploynix Server Monitoring

On Deploynix, monitor your database server's CPU and connection count. A sudden increase in active connections often correlates with N+1 queries — each lazy-loaded query opens and closes a connection (or holds one from the pool). If your connection count spikes during peak traffic, investigate the most active endpoints for N+1 issues.

Deploynix's real-time monitoring dashboard shows MySQL process CPU usage alongside your application server's metrics. When the database CPU spikes but the application CPU stays flat, the cause is almost always excessive queries — and N+1 is the most likely culprit.

Custom Middleware for Query Counting

Add middleware that logs requests exceeding a query threshold:

class QueryCountMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $queryCount = 0;
        DB::listen(function () use (&$queryCount) {
            $queryCount++;
        });

        $response = $next($request);

        if ($queryCount > 30) {
            logger()->warning("High query count: {$queryCount} queries", [
                'url' => $request->fullUrl(),
                'method' => $request->method(),
            ]);
        }

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it globally in development or for specific route groups in production.

Conclusion

N+1 queries are the single most impactful performance problem in Laravel applications, and they're entirely preventable. Enable preventLazyLoading to catch them during development. Write query-count tests to prevent regressions. Use with(), loadMissing(), and withCount() consistently.

In production, monitor query patterns through Pulse and watch for database CPU spikes on your Deploynix dashboard. The combination of prevention at the code level and detection at the infrastructure level means N+1 problems get caught before they become outages.

The goal isn't zero lazy loading — it's intentional lazy loading. Every relationship access should be a deliberate choice, not an accidental database round trip hiding in a Blade component three levels deep.

Top comments (0)