Skip to content

Eloquent N+1 Elimination: Eager Loading Strategies and Query Deduplication at Scale

N+1 queries silently destroy Laravel app performance. This guide covers eager loading strategies, query deduplication with load-once guards, and Eloquent relationship tricks that matter at production scale.

Published · Updated · 4 min read

Eloquent N+1 Elimination: Eager Loading Strategies and Query Deduplication at Scale

The N+1 Problem Is Not Just a Beginner Mistake

Every Laravel developer learns about N+1 queries early. Yet in production codebases with layered services, Blade components, and Livewire, they creep back in — often invisibly. The fix is not just slapping with() everywhere. It requires understanding when Eloquent fires queries, how the relationship cache works, and where deduplication breaks down.

How Eloquent's Relationship Cache Works

When you call $post->comments, Eloquent checks $this->relations on the model. If the key exists (even as an empty collection), it returns the cached result. If not, it fires a query.

This means:

$post->comments; // fires query, caches result
$post->comments; // returns cached collection — no query
$post->load('comments'); // re-queries and overwrites cache
$post->setRelation('comments', collect()); // manually set, prevents any query

Understanding this cache is the foundation of every optimization below.

Eager Loading: with() vs load() vs loadMissing()

with() is for query-time loading. load() re-queries unconditionally. loadMissing() is the underused hero — it only fires a query if the relation is not already cached.

// Bad: always re-queries even if already loaded
$posts->each(fn($p) => $p->load('author'));

// Good: skips already-loaded relations
$posts->loadMissing('author');

In service classes that receive models from different call sites, loadMissing() is the safe default.

Nested Eager Loading and Constraint Closures

Dot notation handles nesting, but constraints require closures:

Post::with([
    'comments' => fn($q) => $q->where('approved', true)->latest()->limit(5),
    'comments.author:id,name,avatar',
])->get();

Note the column selection on comments.author — always select the foreign key (id) plus only what you need. Omitting the FK breaks the relation hydration silently.

The withCount and withExists Shortcuts

Avoid loading full collections just to count them:

// Bad: loads all comments into memory to count
$post->comments->count();

// Good: single subquery, adds comments_count attribute
Post::withCount('comments')->get();

// Even cheaper when you only need a boolean
Post::withExists('comments')->get(); // adds comments_exists

withExists compiles to a correlated EXISTS subquery, which the query planner can short-circuit on the first matching row.

Detecting N+1 in CI with Telescope and preventLazyLoading()

Laravel ships a first-class guard:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

This throws a LazyLoadingViolationException the moment any unloaded relation is accessed outside production. Pair it with a Pest architecture test:

it('does not lazy load in feature tests', function () {
    Model::preventLazyLoading();
    // run your feature test suite — any violation throws
});

For production observability, Telescope's query panel groups duplicate queries. If you see the same query repeated with different bindings, that is your N+1.

Load-Once Guards in Service Classes

When a service method is called from multiple entry points, guard against redundant loads:

class PostEnricher
{
    public function enrich(Post $post): Post
    {
        $post->loadMissing(['author', 'tags', 'category']);

        // safe to access now — no extra queries if already loaded
        return $post;
    }
}

This pattern is especially valuable in Filament resource pages where the same model flows through multiple actions and infolist entries.

Chunked Processing and Eager Loading

When iterating large datasets, chunk() with with() is correct; cursor() is not — cursor returns a lazy generator of individual models with no batch context, so every relation access fires a query.

// Good: each chunk of 500 eager-loads relations in 2 queries
Post::with('author')->chunk(500, function ($posts) {
    $posts->each(fn($p) => ProcessPost::dispatch($p));
});

// Dangerous for relations: each model is hydrated alone
Post::cursor()->each(fn($p) => $p->author->name); // N+1

Use cursor() only when you need memory efficiency on flat, relation-free queries.

Takeaways

  • Use loadMissing() in services — it is the safe, idempotent alternative to load().
  • Always select the FK column when constraining nested eager loads.
  • Prefer withCount / withExists over loading collections for aggregates.
  • Enable Model::preventLazyLoading() in non-production environments and enforce it in CI.
  • chunk() + with() is the correct pattern for large dataset iteration; cursor() is not relation-safe.
  • Profile with Telescope query grouping before optimizing — measure first.

Frequently asked questions

Does `loadMissing()` work on Eloquent collections as well as individual models?

Yes. Calling `$collection->loadMissing('relation')` inspects each model in the collection, groups those missing the relation, and fires a single batched query for them — the same efficiency as `with()` at query time.

Will `Model::preventLazyLoading()` break existing tests that don't eager-load?

It will throw exceptions for any lazy-loaded relation access, which is the point. Treat each violation as a bug to fix by adding the appropriate `with()` or `loadMissing()` call. Start with it enabled only in feature tests, then expand coverage.

When should I use `withAggregate()` instead of `withCount()`?

`withCount()` is a convenience wrapper around `withAggregate()`. Use `withAggregate()` directly when you need `sum`, `avg`, `min`, or `max` on a related column — for example, `Post::withAggregate('comments', 'rating', 'avg')` adds a `comments_avg_rating` attribute.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project