Lập trình · 22/09/2026

Laravel 13 Course – Lesson 12: Avoiding N+1 with Eager Loading in Laravel 13

Lesson 12 — N+1 and Eager Loading: after Eloquent relationships, measure the cost of reading each task's project name. The goal is equal output with fewer queries, not adding with() everywhere or loading unnecessary data.

Tránh N+1 với Eager Loading trong Laravel 13

Lesson 12 — N+1 and Eager Loading: after Eloquent relationships, measure the cost of reading each task's project name. The goal is equal output with fewer queries, not adding with() everywhere or loading unnecessary data.

1. Reproduce N+1 with five tasks

$names = Task::orderBy('id')->get()
    ->map(fn ($task) => $task->project->name);

Each fixture task has a separate project. One query loads the tasks, then five unloaded project accesses issue five more queries: six total. A short expression can hide multiple round trips, including when used in Blade or a Resource.

Measure only reads after fixture creation, excluding migrations and factories. Mixing setup queries into the count obscures the code under investigation. Do not keep an unbounded in-memory query log enabled in a long-running production worker.

2. Load relationships before looping

$names = Task::with('project')->orderBy('id')->get()
    ->map(fn ($task) => $task->project->name);

For this fixture, one tasks query plus one projects query gives two total. The test also compares outputs so reduced query counts cannot hide missing data. This is not a universal two-query guarantee: nested relationships, multiple relation types, pagination and scopes may change counts.

3. Count without hydrating a collection

$project = Project::withCount('tasks')->findOrFail($projectId);
$count = $project->tasks_count;

A project list needing only task totals should not hydrate thousands of Task objects to count them. withCount supplies an aggregate without marking tasks as loaded. Use an explicitly named constrained count for todo tasks instead of presenting the total as unfinished work.

4. Preserve relationship keys when selecting columns

$task = Task::select(['id', 'project_id', 'title'])
    ->with('project:id,name')->findOrFail($taskId);

The task's project_id and project's id are needed for matching. Removing keys simply because the UI does not display them can prevent correct relationship hydration. Column reduction should preserve the linking contract.

5. Test a bounded measurement

Create tests/Feature/TaskFlowLoadingTest.php:

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class TaskFlowLoadingTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_eager_loading_reduces_project_reads_without_changing_output(): void
    {
        Task::factory()->count(5)->create();
        DB::enableQueryLog();
        try {
            DB::flushQueryLog();
            $lazyNames = Task::orderBy('id')->get()->map(fn ($task) => $task->project->name)->all();
            $lazyCount = count(DB::getQueryLog());

            DB::flushQueryLog();
            $eagerNames = Task::with('project')->orderBy('id')->get()
                ->map(fn ($task) => $task->project->name)->all();
            $eagerCount = count(DB::getQueryLog());

            $this->assertSame($lazyNames, $eagerNames);
            $this->assertSame(6, $lazyCount);
            $this->assertSame(2, $eagerCount);
        } finally {
            DB::disableQueryLog();
            DB::flushQueryLog();
        }
    }

    public function test_with_count_does_not_hydrate_all_tasks(): void
    {
        $project = Project::factory()->create();
        Task::factory()->count(3)->for($project)->create();
        $loaded = Project::withCount('tasks')->findOrFail($project->id);
        $this->assertSame(3, $loaded->tasks_count);
        $this->assertFalse($loaded->relationLoaded('tasks'));
    }

    public function test_selected_columns_keep_keys_needed_for_eager_matching(): void
    {
        $task = Task::factory()->create();
        $loaded = Task::select(['id', 'project_id', 'title'])->with('project:id,name')->findOrFail($task->id);
        $this->assertSame($task->project_id, $loaded->project->id);
    }
}
php artisan test --filter=TaskFlowLoadingTest
php artisan test

Verified: five tasks, six lazy-loading queries, two eager-loading queries and identical output. The complete suite passed 30 tests with 100 assertions. Query logs are reset between measurements and disabled in finally. This is a SQLite query-count check, not a production latency benchmark.

6. Avoid overcorrecting

  • Do not eager load every deep relationship for every screen; fewer queries returning millions of rows still consume memory.
  • Bound or paginate parent records before rendering; the next lesson implements query controls.
  • Do not call tasks() inside a loop when you intend to use the already loaded tasks collection; the method can issue another query.
  • Inspect the complete task → project → owner access path rather than only its first relation.
  • Query counts do not replace index checks, timings, response-size measurements or execution plans.

Development lazy-loading warnings or prevention can help after existing dependencies on lazy loading are understood. Automatic framework features also need measurement under the actual configuration rather than an assumption that N+1 disappeared. Compare the eager-loading documentation.

7. Exercise

Increase the fixture to twenty tasks and predict counts before running. Add owner names to the output, measure again and choose suitable nested loading. Then create several tasks sharing one project and explain why a global Eloquent identity map should not be assumed to remove repeated queries.

Navigation: Lesson 11 · Roadmap. Next comes filtering, searching, sorting and pagination.

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.