DEV Community

Sumeet Shroff
Sumeet Shroff

Posted on Originally published at mumbaiwebdesigner.com

Finding and Fixing the Eloquent N+1 Problem

Finding and Fixing the Eloquent N+1 Problem

You deploy a feature, and within days the DBA flags your page as a top query offender. A single request is issuing 101 queries where one would do. This is the N+1 problem — the most common performance mistake in Eloquent applications and, fortunately, one of the most straightforward to fix once you know where to look.

Prerequisites

  • Laravel 10.x or higher (examples verified against Laravel 12 / PHP 8.2+)
  • Familiarity with Eloquent models and basic relationships
  • A local dev environment running
  • Composer dev dependencies for detection tooling (details below)

What N+1 Actually Looks Like in Practice

Consider a blog listing page. The controller loads posts and the Blade (or API response) iterates them:

// Controller
$posts = Post::all();

// Blade / resource loop
foreach ($posts as $post) {
    echo $post->author->name; // relationship access inside the loop
}
Enter fullscreen mode Exit fullscreen mode

Eloquent lazy-loads author on first access for each $post. With 100 posts, that is:

  • 1 query: SELECT * FROM posts
  • 100 queries: SELECT * FROM users WHERE id = ? — one per post

Total: 101 queries. The pattern scales linearly with the record count, which means it is invisible during local testing with five seed records and catastrophic in production with fifty thousand.


Step 1 — Detect the Problem Before You Fix It

Fix N+1 issues you can see. Three approaches, from lightest to most automated:

DB::enableQueryLog() — Zero-Dependency Inspection

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$posts = Post::all();
foreach ($posts as $post) {
    $_ = $post->author->name;
}

$queries = DB::getQueryLog();
echo count($queries); // 101 with 100 posts
dd($queries); // inspect full SQL + bindings
Enter fullscreen mode Exit fullscreen mode

This is the simplest approach and works in every environment with no package installation.

Model::preventLazyLoading() — Throw Early in Development

Added to app/Providers/AppServiceProvider.php:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(!app()->isProduction());
}
Enter fullscreen mode Exit fullscreen mode

With this in place, any lazy relationship access throws a LazyLoadingViolationException during local development:

Illuminate\Database\LazyLoadingViolationException:
Attempted to lazy load [author] on model [App\Models\Post]
but lazy loading is disabled.
Enter fullscreen mode Exit fullscreen mode

This is the most effective way to catch N+1 issues before they ship. It does not block lazy loading in production — that is intentional so that third-party packages that rely on lazy loading do not break. Complete your with() calls and re-test; the exception disappears.

beyondcode/laravel-query-detector — Real-Time Auto-Detection

composer require beyondcode/laravel-query-detector --dev
Enter fullscreen mode Exit fullscreen mode

After installation and publishing the config (php artisan vendor:publish --provider="BeyondCode\QueryDetector\QueryDetectorServiceProvider"), the package automatically detects N+1 patterns in real-time and can surface them via the browser console, Debugbar, or log file. This is the least invasive option for teams that want passive monitoring rather than hard exceptions.


Step 2 — Fix with Eager Loading

Eager loading with with() is the primary solution. It replaces the per-iteration lazy load with a single additional query, regardless of how many records the initial query returns.

// Before — 101 queries for 100 posts
$posts = Post::all();

// After — 2 queries total
$posts = Post::with('author')->get();
Enter fullscreen mode Exit fullscreen mode

Eloquent now runs:

  1. SELECT * FROM posts
  2. SELECT * FROM users WHERE id IN (1, 2, 3, ...) — all author IDs in one IN clause

Nested Relationships

Chain dot-notation to eager load through multiple levels:

// Loads posts → comments → comment authors in 3 total queries
$posts = Post::with('comments.author')->get();
Enter fullscreen mode Exit fullscreen mode

Constrained Eager Loading

Filter the eager-loaded relationship using a closure:

$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)->orderBy('created_at', 'desc');
}])->get();
// $post->comments only contains approved comments, still in 2 queries
Enter fullscreen mode Exit fullscreen mode

Column Selection — Avoid SELECT * Overhead

When eager loading, always specify only the columns you actually need. This is particularly important when the related model has large text or JSON columns:

// Bad — loads all user columns for every post (including password hash, settings JSON, etc.)
$posts = Post::with('author')->get();

// Good — only the columns the view uses
$posts = Post::select('id', 'title', 'user_id')
    ->with(['author:id,name,avatar_url'])
    ->get();
Enter fullscreen mode Exit fullscreen mode

Critical rule: When constraining eager-loaded columns using the colon syntax, you must always include both the primary key (id) and the foreign key (user_id on the parent). If you omit the primary key on the relation, Eloquent cannot match related records back to their parents, and $post->author will always be null.


Step 3 — Always-Eager Relationships with $with

If a relationship is needed on virtually every query for a model, define it on the model directly:

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

Every Post::query() now eager loads author automatically. Override on a per-query basis with without():

// Suppresses the automatic eager load for this query only
$posts = Post::without('author')->get();
Enter fullscreen mode Exit fullscreen mode

Use $with judiciously. If a relationship is only needed in a few contexts, $with adds unnecessary JOIN/subquery overhead to every other query. Prefer explicit with() at the call site unless the relationship is genuinely used in the majority of code paths.


Common Variant: The Hidden N+1 Inside a Loop Condition

N+1 does not always look like $post->author->name. It can hide inside conditionals and method calls:

// Still N+1 — accessing relationship inside an if
foreach ($posts as $post) {
    if ($post->tags->count() > 2) {
        // render tagged post differently
    }
}

// Fix — eager load tags
$posts = Post::with('tags')->get();
foreach ($posts as $post) {
    if ($post->tags->count() > 2) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Another common variant: loading one relationship with with() but accessing a different, non-eager-loaded relationship inside the same loop:

// Only 'author' is eager loaded — accessing 'category' still causes N+1
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->category->name; // N+1 on category
}

// Fix — eager load both
$posts = Post::with('author', 'category')->get();
Enter fullscreen mode Exit fullscreen mode

When Boolean Presence Is All You Need: withExists()

A common pattern is checking whether a relationship exists rather than loading its data. Using withCount() for this works but fires a COUNT(*) subquery for each row. withExists() is more efficient — it generates a cheaper EXISTS subquery instead:

// withCount — generates COUNT(*) subquery, returns integer
$posts = Post::withCount('comments')->get();
// $post->comments_count === 5

// withExists — generates EXISTS subquery, returns boolean
$posts = Post::withExists('comments')->get();
// $post->comments_exists === true
// Use this when you only need to know if comments exist, not how many
Enter fullscreen mode Exit fullscreen mode

Both run as subqueries within the main SELECT, not as separate queries. Pick withExists() whenever the count itself is not needed.


Large Dataset Caveat: cursor() Does Not Support with()

For iterating large result sets, Laravel offers cursor(), which uses a single unbuffered query and hydrates one model at a time — approximately 1.87 MB for 300,000 rows versus batch-size times model-size for chunk().

However, cursor() does not support eager loading with with(). Chaining them does not throw an error, but relationship access inside the loop is lazy-loaded, which puts you squarely back in N+1 territory:

// WRONG — cursor() + relationship access = N+1, no error thrown
foreach (Post::cursor() as $post) {
    echo $post->author->name; // still N+1
}

// Use chunk() instead when you need eager loading on large datasets
Post::with('author')->chunk(500, function ($posts) {
    foreach ($posts as $post) {
        echo $post->author->name; // safe — author is eager loaded per chunk
    }
});
Enter fullscreen mode Exit fullscreen mode

If memory usage is critical and you genuinely cannot use chunk(), restructure the loop to avoid relationship access — preload the related data as a keyed collection before the cursor loop:

// Preload all authors as a lookup map
$authors = User::pluck('name', 'id'); // keyed by user_id

foreach (Post::cursor() as $post) {
    echo $authors[$post->user_id]; // O(1) array lookup, no DB query
}
Enter fullscreen mode Exit fullscreen mode

Testing That You Actually Fixed It

Do not rely solely on visual confirmation. Write a feature test that asserts the query count:

public function test_post_listing_does_not_cause_n_plus_1(): void
{
    // Create 10 posts each with an author
    $users = User::factory()->count(10)->create();
    Post::factory()
        ->count(10)
        ->sequence(fn ($seq) => ['user_id' => $users[$seq->index % 10]->id])
        ->create();

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

    // Simulate the controller
    $posts = Post::with('author')->get();
    foreach ($posts as $post) {
        $_ = $post->author->name;
    }

    // Should be exactly 2 queries: one for posts, one for authors
    $this->assertEquals(2, $queryCount);
}
Enter fullscreen mode Exit fullscreen mode

This test will fail if someone later removes with('author') and reintroduces N+1. Add it to CI and it becomes a permanent guard.


Tradeoffs and Limitations

Eager loading vs lazy loading — memory cost. Eager loading trades multiple small queries for a single larger one. If the related dataset is huge (e.g., a post with 50,000 comments), eager loading all of them into memory is worse than lazy loading. Use constrained eager loading with limit() or paginate the relationship in those cases.

preventLazyLoading() in production. The guard only activates when !app()->isProduction() by default. Running it unconditionally in production may break third-party packages that use lazy loading — keep it development-only.

withExists() and withCount() — subquery cost. Both add a correlated subquery per row to the main SELECT. For large tables, ensure foreign key columns are indexed or these subqueries can be slow.

$with on models. Always-eager relationships add overhead to every query. If a relationship is only needed in a minority of code paths, prefer explicit with() at the call site.


For a broader look at query optimization in Laravel — covering upsert() for bulk writes, aggregate subqueries, large-dataset strategies, and vector search in Laravel 13 — see Eloquent and Database Optimization: Relationships, N+1, Upserts, and Vector Search.

If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

Hello Sumeet, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.

Your explanation of Eloquent N+1 is particularly practical because the real challenge is not knowing with(), but preventing query regressions as an application evolves. I would take this one step further by treating query behavior as an architectural invariant.

In larger Laravel systems, I usually combine Model::preventLazyLoading() with feature tests, query count assertions, Laravel Telescope or query monitoring, and database performance metrics. This creates protection across development, CI, and production rather than relying on developers to manually inspect loops.

Another useful technique is to move relationship requirements into API Resources or dedicated query objects so the data access contract is explicit. For example, a resource can define that author, category, and selected aggregates are required before serialization. This helps prevent hidden N+1 queries introduced inside transformers.

For high traffic endpoints, I would also examine execution plans, composite indexes, cardinality, hydration overhead, and response serialization. Sometimes reducing queries from 101 to 2 is only the first optimization. A poorly indexed IN query or excessive model hydration can still become the bottleneck.

Your point about cursor() is especially important. Streaming and eager loading have fundamentally different memory and query semantics, so choosing between cursor(), chunkById(), and eager loading should depend on workload characteristics rather than simply dataset size.

I would like to get to know you better and discuss about your post. Would you please contact me? t_g_@kanelim1997