Masalah N+1 adalah penyakit paling mahal di aplikasi ber-ORM: kode terlihat benar, hasilnya benar, tetapi database mengerjakan ratusan query untuk satu halaman. Chapter Lazy Loading sudah menunjukkan bentuk dasarnya — enam post berarti tujuh query — dan sepanjang section ini kita membangun pertahanannya satu per satu. Chapter penutup ini menyusun semuanya menjadi satu alur kerja lengkap: ukur kerusakan dengan query log, perbaiki dengan eager loading , lalu pasang penjaga agar pola itu tidak kembali diam-diam. Sebagai bonus, kita bedah dua jebakan yang membuat N+1 lolos dari mata telanjang: relasi bersarang dan accessor.

Mengukur Masalahnya #

N+1 paling licik saat bersembunyi di belakang eager loading yang setengah jadi. Kode berikut sudah memakai with('comments') — namun di dalam komentar, $c->user->name tetap dimuat malas:

php
use App\Models\Post;
use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

foreach (Post::with('comments')->orderBy('id')->get() as $post) {
    $nama = $post->comments->map(fn ($c) => $c->user->name);
    echo $post->judul, ': ', $nama->implode(', '), "\n";
}

$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach (array_slice($log, 0, 4) as $q) {
    echo '[', $q['query'], "]\n";
}

Output yang Dihasilkan:

text
Belajar Relasi 01: Siti, Anton, Siti
Belajar Relasi 02: Budi, Budi
Belajar Relasi 03: 
Belajar Relasi 04: Anton, Budi, Siti
Belajar Relasi 05: Siti
Belajar Relasi 06: 
total query: 11
[select * from "posts" order by "id" asc]
[select * from "comments" where "comments"."post_id" in (1, 2, 3, 4, 5, 6)]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]

Empat baris pertama log memperlihatkan polanya: query posts, query comments, lalu query users yang berulang — sembilan komentar berarti sembilan query tambahan, total 11. Perbaikan setengah jadi seperti ini justru lebih berbahaya daripada tidak memuat sama sekali, karena rasanya sudah “aman” padahal tidak.

Perbaikannya: Eager Loading Bersarang #

Notasi titik pada with() sekaligus memuat relasi dari relasi:

php
DB::flushQueryLog();

foreach (Post::with('comments.user')->orderBy('id')->get() as $post) {
    $nama = $post->comments->map(fn ($c) => $c->user->name);
    echo $post->judul, ': ', $nama->implode(', '), "\n";
}

$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach ($log as $q) {
    echo '[', $q['query'], "]\n";
}

Output yang Dihasilkan:

text
Belajar Relasi 01: Siti, Anton, Siti
Belajar Relasi 02: Budi, Budi
Belajar Relasi 03: 
Belajar Relasi 04: Anton, Budi, Siti
Belajar Relasi 05: Siti
Belajar Relasi 06: 
total query: 3
[select * from "posts" order by "id" asc]
[select * from "comments" where "comments"."post_id" in (1, 2, 3, 4, 5, 6)]
[select * from "users" where "users"."id" in (1, 2, 3)]

Output tampilan identik, biaya turun dari 11 query ke 3. Semua penulis dikumpulkan dalam satu where ... in (1, 2, 3) — dan jumlah ini tetap tiga berapa pun jumlah post maupun komentarnya. Sebelum memasang perbaikan, pastikan dulu polanya memang N+1: baca SQL dari query log, cari baris identik yang berulang dengan binding berbeda.

Deteksi Dini dengan preventLazyLoading #

Query log berguna saat menyelidiki, tetapi penjaga permanen bekerja sebaliknya: melarang lazy loading sejak awal. Dengan Model::preventLazyLoading() yang kita pasang di AppServiceProvider (lihat chapter Lazy Loading ), pelanggaran pertama langsung meledak:

php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;

Model::preventLazyLoading(true);

try {
    foreach (Post::orderBy('id')->get() as $post) {
        echo $post->user->name, "\n";
    }
} catch (LazyLoadingViolationException $e) {
    echo 'Exception: ', $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
Exception: Attempted to lazy load [user] on model [App\Models\Post] but lazy loading is disabled.

Pesan exception menunjuk persis lokasinya: relasi user pada App\Models\Post. Di aplikasi nyata, stack trace membawa kita ke baris kode yang menyentuh relasi tersebut tanpa with() — perbaikannya tinggal menambahkan eager loading di query terkait.

Menghitung Tanpa Menghentikan Eksekusi #

Melempar exception kadang terlalu keras — misalnya saat memindai proyek lama yang penuh pelanggaran. Callback handleLazyLoadingViolationUsing() mengubah reaksi default menjadi penghitung:

php
$hitung = 0;
Model::preventLazyLoading(true);
Model::handleLazyLoadingViolationUsing(function ($model, $relation) use (&$hitung) {
    $hitung++;
});

DB::flushQueryLog();

foreach (Post::orderBy('id')->get() as $post) {
    $post->user->name;
}

echo 'pelanggaran: ', $hitung, ' | query: ', count(DB::getQueryLog()), "\n";

Output yang Dihasilkan:

text
pelanggaran: 6 | query: 7

Eksekusi tetap berjalan (relasi tetap termuat malas), tetapi setiap pelanggaran tercatat — enam akses malas, tujuh query. Pola serupa bisa dipakai untuk menulis log peringatan alih-alih menghitung. Pengawasan level query juga tersedia lewat DB::listen(), yang memanggil callback untuk setiap query berjalan:

php
$jumlah = 0;
DB::listen(function ($query) use (&$jumlah) {
    $jumlah++;
});

foreach (Post::orderBy('id')->get() as $post) {
    $post->user->name;
}

echo 'query terekam listener: ', $jumlah, "\n";

Output yang Dihasilkan:

text
query terekam listener: 7

Paket seperti Laravel Debugbar dan Telescope membangun seluruh tampilan query-per-request di atas mekanisme yang sama.

Jebakan Tersembunyi: Accessor dan Serialisasi #

Sumber N+1 tidak selalu terlihat di kode controller. Accessor yang menyentuh relasi akan memicu lazy load kapan pun dipanggil — termasuk saat model di-serialisasi ke JSON bila accessor masuk daftar $appends:

php
class CommentWithAuthor extends Comment
{
    protected $table = 'comments';
    protected $appends = ['nama_penulis'];

    public function getNamaPenulisAttribute()
    {
        return $this->user->name;
    }
}

$comments = CommentWithAuthor::orderBy('id')->limit(3)->get();
foreach ($comments as $comment) {
    echo 'komentar #', $comment->id, ' oleh ', $comment->nama_penulis, "\n";
}
echo 'total query: ', count(DB::getQueryLog()), "\n";

Output yang Dihasilkan:

text
komentar #1 oleh Siti
komentar #2 oleh Anton
komentar #3 oleh Siti
total query: 4

Tiga komentar, empat query — select * from "comments" ... limit 3 diikuti tiga query users yang identik. Efek sampingnya pun nyata: karena accessor memuat relasi user ke property relations, JSON yang dihasilkan toJson() ikut menyisipkan objek user lengkap — "nama_penulis":"Siti","user":{"id":2,...}. Untuk model seperti ini, eager load wajib dinyalakan sejak query: CommentWithAuthor::with('user')->... membuat semua akses accessor bebas query.

Kapan N+1 Masih Wajar #

KondisiContohPenilaian
N kecil dan tetapMengolah 3 post pilihan lewat Post::find([1, 3, 6])4 query sekali jalan — tidak perlu rumit
Skrip admin sekali pakaiRekap data yang berjalan semalamKejelasan kode lebih penting daripada milidetik
Halaman publik, data besarDaftar 200 post beserta relasiWajib eager loading
Relasi di dalam accessor/serialisasi$appends seperti di atasWajib eager loading + uji query log

Bukti untuk kasus pertama — tiga post dari find([1, 3, 6]):

php
DB::flushQueryLog();

foreach (Post::find([1, 3, 6]) as $post) {
    echo $post->judul, ' — ', $post->user->name, "\n";
}
echo 'total query: ', count(DB::getQueryLog()), "\n";

Output yang Dihasilkan:

text
Belajar Relasi 01 — Budi
Belajar Relasi 03 — Budi
Belajar Relasi 06 — Siti
total query: 4

Empat query untuk tiga model adalah harga yang pantas untuk kode yang ringkas. Aturan praktisnya: khawatirkan N yang tumbuh mengikuti data pengguna, bukan N kecil yang kita kendalikan.

Dengan pengukuran (getQueryLog(), DB::listen()), perbaikan (with() bersarang), dan penjaga (preventLazyLoading()), siklus lengkap melawan N+1 sudah di tangan kita. Section Eloquent Relationships pun tertutup: dari definisi relasi di awal section sampai operasi tulis dan pivot table, semua pola di sini dirancang agar model yang saling terhubung tetap cepat — dan tetap jujur terhadap query yang benar-benar dijalankannya.