Chapter Query Optimization menutup diskusi Eloquent ORM dengan sekilas masalah N+1: satu query untuk koleksi utama, lalu N query tambahan karena relasi dimuat terpisah di dalam loop. Section ini adalah tempat membahas obatnya secara utuh. Method with() pada Eloquent memuat relasi di muka — bersamaan dengan query utama — sehingga satu halaman yang tadinya menjalankan puluhan query bisa turun menjadi dua-tiga query saja. Kita akan memakai data yang sama seperti section sebelumnya: Budi dan Siti menulis enam post secara bergantian, diselingi sembilan komentar dari tiga user, dan setiap bukti query kita ukur lewat query log.

Satu Relasi: with('user') #

Bentuk paling dasar eager loading adalah menambahkan with() pada query sebelum get():

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

DB::enableQueryLog();

foreach (Post::with('user')->orderBy('id')->get() as $post) {
    echo $post->judul, ' — ', $post->user->name, "\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 — Budi
Belajar Relasi 02 — Siti
Belajar Relasi 03 — Budi
Belajar Relasi 04 — Siti
Belajar Relasi 05 — Budi
Belajar Relasi 06 — Siti
total query: 2
[select * from "posts" order by "id" asc]
[select * from "users" where "users"."id" in (1, 2)]

Perhatikan query keduanya: Eloquent mengumpulkan seluruh user_id dari hasil query pertama, lalu mengambil semua user sekaligus dengan satu klausa where ... in (1, 2) dan mencocokkan setiap post dengan user-nya di memori. Tanpa with(), loop yang sama menjalankan tujuh query; dengan with(), hanya dua — dan jumlahnya tetap dua berapa pun banyak post yang kita tampilkan.

Beberapa Relasi Sekaligus #

Argumen with() menerima array bila kita butuh lebih dari satu relasi:

php
DB::flushQueryLog();

$posts = Post::with(['user', 'comments'])->orderBy('id')->get();

foreach ($posts as $post) {
    echo $post->judul, ' oleh ', $post->user->name,
        ' punya ', $post->comments->count(), " komentar\n";
}
echo 'total query: ', count(DB::getQueryLog()), "\n";

Output yang Dihasilkan:

text
Belajar Relasi 01 oleh Budi punya 3 komentar
Belajar Relasi 02 oleh Siti punya 2 komentar
Belajar Relasi 03 oleh Budi punya 0 komentar
Belajar Relasi 04 oleh Siti punya 3 komentar
Belajar Relasi 05 oleh Budi punya 1 komentar
Belajar Relasi 06 oleh Siti punya 0 komentar
total query: 3

Tiga query: satu untuk posts, satu untuk users, satu untuk comments. Relasi yang tidak kita sebut tidak ikut dimuat — memanggil $post->user->email di sini bebas query, tetapi menyentuh relasi lain yang belum dimuat tetap memicu query baru.

Eager Loading Bersarang #

Notasi titik memuat relasi dari relasi: comments.user berarti “setiap komentar beserta penulisnya”. Pola ini penting karena N+1 bisa bersarang — post memuat komentar, komentar memuat user:

php
DB::flushQueryLog();

$posts = Post::with('comments.user')->where('id', '<=', 2)->orderBy('id')->get();

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->user->name, ' berkomentar di ', $post->judul, ': ',
            $comment->isi, "\n";
    }
}

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

Output yang Dihasilkan:

text
Siti berkomentar di Belajar Relasi 01: Penjelasannya jelas, terima kasih!
Anton berkomentar di Belajar Relasi 01: Baru mulai belajar, sangat membantu.
Siti berkomentar di Belajar Relasi 01: Promosi produk murah, cek bio saya.
Budi berkomentar di Belajar Relasi 02: Contoh kodenya bisa diunduh?
Budi berkomentar di Belajar Relasi 02: Jangan lupa bahas eager loading.
total query: 3
[select * from "posts" where "id" <= ? order by "id" asc]
[select * from "comments" where "comments"."post_id" in (1, 2)]
[select * from "users" where "users"."id" in (1, 2, 3)]

Lima komentar dari tiga penulis hanya membutuhkan satu query users — tiga level relasi (post → comment → user) selesai dalam tiga query.

Eager Loading dengan Kendala #

Terkadang kita hanya ingin memuat sebagian relasi — misalnya komentar yang sudah di-approve saja. Nilai array diganti menjadi pasangan relasi => closure:

php
$posts = Post::with(['comments' => fn ($query) => $query->where('approved', true)])
    ->orderBy('id')->get();

foreach ($posts as $post) {
    echo $post->judul, ': ', $post->comments->count(), " komentar approved\n";
}

Output yang Dihasilkan:

text
Belajar Relasi 01: 2 komentar approved
Belajar Relasi 02: 1 komentar approved
Belajar Relasi 03: 0 komentar approved
Belajar Relasi 04: 2 komentar approved
Belajar Relasi 05: 1 komentar approved
Belajar Relasi 06: 0 komentar approved

Closure menerima query builder relasi, sehingga where, orderBy, sampai limit bisa kita rantai di situ. Perlu dicatat batas pentingnya: $post->comments setelah ini hanya berisi komentar approved — kode lain yang mengharapkan koleksi lengkap akan melihat data yang sudah terfilter.

Menghitung Tanpa Memuat: withCount() #

Bila yang dibutuhkan hanya jumlah, memuat seluruh baris komentar sayang. withCount() menambahkan subquery count(*) sebagai kolom virtual:

php
$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => fn ($q) => $q->where('approved', true),
])->orderBy('id')->get();

foreach ($posts as $post) {
    echo $post->judul, ': ', $post->comments_count, ' total / ',
        $post->approved_comments_count, " approved\n";
}

Output yang Dihasilkan:

text
Belajar Relasi 01: 3 total / 2 approved
Belajar Relasi 02: 2 total / 1 approved
Belajar Relasi 03: 0 total / 0 approved
Belajar Relasi 04: 3 total / 2 approved
Belajar Relasi 05: 1 total / 1 approved
Belajar Relasi 06: 0 total / 0 approved

Alias as approved_comments_count membuat hitungan terfilter menjadi atribut tersendiri. SQL yang dihasilkan berbentuk select "posts".*, (select count(*) from "comments" where "posts"."id" = "comments"."post_id") as "comments_count" from "posts" — tanpa memuat satu pun model Comment ke memori.

load() Setelah Model Diambil #

Eager loading tidak harus diputuskan sebelum query. Method load() pada model yang sudah ada memuat relasi setelahnya:

php
$post = Post::first();

echo 'sudah termuat? ', var_export($post->relationLoaded('user'), true), "\n";
$post->load('user');
echo 'sudah termuat? ', var_export($post->relationLoaded('user'), true), "\n";
echo $post->user->name, "\n";

Output yang Dihasilkan:

text
sudah termuat? false
sudah termuat? true
Budi

Pola ini berguna saat relasi baru diketahui dibutuhkan di tengah kode — misalnya di dalam method controller setelah beberapa percabangan. Versi loadMissing() bersifat lebih sopan: relasi yang sudah termuat tidak dimuat ulang.

Setelah load() atau with(), relasi menetap di property relations model. Akses $post->user->name tiga kali pun tidak menambah query — Eloquent memeriksa relationLoaded() lebih dulu dan hanya menjalankan query bila relasi belum ada di memori. Mekanisme cache inilah yang akan kita bedah dari sisi sebaliknya di chapter berikutnya: lazy loading, perilaku bawaan saat kita lupa memuat relasi sejak awal.