Eloquent Query Optimization: Killing N+1 Problems at the Source
#laravel #eloquent #performance #database #optimization

Eloquent Query Optimization: Killing N+1 Problems at the Source

3 min read Mohamed Said Mohamed Said

The N+1 Problem Is Still Killing Your App

Every Laravel developer knows what an N+1 query is. Far fewer have a systematic strategy for preventing them at the architectural level. This article goes beyond the basics — we'll look at detection tooling, subtle N+1 patterns that survive code review, and structural approaches that make the problem harder to introduce in the first place.

Detecting N+1 Queries in Development

The fastest feedback loop is DB::listen or a dedicated package. In a service provider or test bootstrap:

DB::listen(function (QueryExecuted $event) {
    if (str_contains($event->sql, 'select')) {
        logger()->debug($event->sql, ['time' => $event->time]);
    }
});

For a more structured approach, Laravel Debugbar groups duplicate queries visually. In CI, use spatie/laravel-query-detector to fail tests when N+1 queries are detected automatically.

// In a Pest test
use Spatie\QueryDetector\QueryDetector;

it('loads posts without N+1', function () {
    app(QueryDetector::class)->enable();

    $posts = Post::with('author', 'tags')->get();
    $posts->each(fn ($p) => $p->author->name);

    expect(app(QueryDetector::class)->getDetectedQueries())->toBeEmpty();
});

The Obvious Fix: Eager Loading

You already know with(). The subtlety is when to apply it.

// Fragile: caller must remember to eager-load
$posts = Post::all();

// Better: scope it at the model level for common access patterns
class Post extends Model
{
    protected $with = ['author']; // always eager-load author
}

Use $with sparingly — it runs on every query including ones that don't need the relation. Prefer explicit with() calls in repository or query builder methods.

Nested Eager Loading

$posts = Post::with([
    'comments.author',
    'tags',
    'media' => fn ($q) => $q->where('type', 'cover'),
])->get();

Constrained eager loads let you filter the relation without triggering extra queries.

Subtle N+1 Patterns That Survive Review

Lazy Loading Inside Blade

{{-- This fires a query per post --}}
@foreach ($posts as $post)
    {{ $post->author->name }}
@endforeach

Enable Model::preventLazyLoading() in AppServiceProvider for non-production environments:

Model::preventLazyLoading(! app()->isProduction());

This throws a LazyLoadingViolationException immediately, surfacing the problem during development.

Counting Relations

// N+1: fires a COUNT query per post
$posts->each(fn ($p) => $p->comments->count());

// Correct: one extra query total
$posts = Post::withCount('comments')->get();
// Access via $post->comments_count

Polymorphic Relations

Polymorphic morphTo relations are notorious. Use morphWith to eager-load the correct related types:

$activities = Activity::with('subject')->get();
// subject could be Post, Comment, or Video — each type fires its own query

// Better: constrain per type
$activities = Activity::with([
    'subject' => fn ($morph) => $morph
        ->morphWith([
            Post::class => ['author'],
            Comment::class => ['post'],
        ]),
])->get();

Architectural Prevention

The most durable fix is structural. If your query logic lives in a dedicated class, you control the eager loads in one place:

final class PostListQuery
{
    public function handle(int $perPage = 20): LengthAwarePaginator
    {
        return Post::query()
            ->with(['author', 'tags', 'media'])
            ->withCount('comments')
            ->latest()
            ->paginate($perPage);
    }
}

Controllers and Filament resources call PostListQuery — they never build raw queries. Eager loading is a concern of the query object, not the caller.

Key Takeaways

  • Enable Model::preventLazyLoading() in development; it catches violations immediately.
  • Use withCount() instead of loading full relation collections just to count them.
  • Constrain eager loads with closures to avoid over-fetching.
  • Centralise query construction in dedicated query classes or repositories to enforce consistent eager loading.
  • Add a Pest assertion using spatie/laravel-query-detector to your critical read paths in CI.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use `$with` on a model versus calling `with()` explicitly?
Use `$with` only for relations that are genuinely required on every access path for that model. For most cases, prefer explicit `with()` calls in query objects or repository methods so you don't over-fetch on queries that don't need the relation.
Q02 Does `Model::preventLazyLoading()` affect production performance?
No. The recommended pattern is `Model::preventLazyLoading(! app()->isProduction())`, which only activates the guard in non-production environments. In production it is a no-op with zero overhead.
Q03 How do I handle N+1 issues in Filament table resources?
Override the `getEloquentQuery()` method on your Filament resource's ListRecords page or the Resource class itself to add `with()` and `withCount()` calls. Filament does not automatically eager-load relations referenced in columns.

Continue reading

More Articles

View all