Mekanisme with() — bagaimana eager loading mengumpulkan foreign key lalu memuat relasi dengan WHERE ... IN — sudah kita kuasai di chapter Eager Loading
. Chapter ini memutar kacamatanya ke performa: berapa persisnya biaya lazy loading pada skala nyata, kapan eager loading justru berlebihan, dan bagaimana memastikan penyakit N+1 tidak kambuh diam-diam setelah refactoring. Semua angka di bawah diukur dari data yang sama: 20 user, 8 kategori, dan 10.000 post di SQLite, dengan halaman daftar yang menampilkan judul, nama user, dan nama category untuk 50 post pertama.
Anatomi 101 Query, Dibayar Setiap Render #
Tanpa with(), loop merender 50 post memicu satu query posts ditambah satu query user dan satu query category untuk setiap baris. Kita ukur dengan query log:
use App\Models\Post;
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$mulai = hrtime(true);
$posts = Post::orderBy('id')->limit(50)->get();
foreach ($posts as $post) {
$baris[] = $post->judul.' | '.$post->user->name.' | '.$post->category->nama;
}
$ms = (hrtime(true) - $mulai) / 1e6;
echo sprintf("50 post, lazy loading: %d query, %.1f ms\n", count(DB::getQueryLog()), $ms);
DB::disableQueryLog();Output yang Dihasilkan:
50 post, lazy loading: 101 query, 26.6 msTambahkan eager loading untuk kedua relasi:
$posts = Post::with(['user', 'category'])->orderBy('id')->limit(50)->get();Output yang Dihasilkan:
50 post, eager loading: 3 query, 3.1 ms101 kolaps menjadi 3, dan waktu render anjlok 26,6 ms → 3,1 ms. Tiga query tersisa itupun terlihat di log: satu untuk posts, satu where "users"."id" in (1, 2, ...) untuk semua penulis, dan satu untuk kategori. Yang menarik adalah laju pertumbuhannya — pada halaman berisi 200 post, lazy loading menagih 401 query dan 55,8 ms, sementara eager loading tetap 3 query dengan 7,2 ms. Biaya eager loading konstan; biaya lazy loading linear terhadap jumlah baris.
Muat Hanya yang Dirender: withCount() vs with() #
Eager loading bukan jawaban untuk semua kasus. Untuk halaman yang hanya menampilkan jumlah relasi — “kategori ini punya berapa post” — memuat seluruh post agar dihitung di PHP adalah pemborosan:
use App\Models\Category;
// dengan with: memuat 10.000 model Post utuh
$mulai = hrtime(true);
$jumlah = Category::with('posts')->get()->map(fn ($c) => $c->posts->count());
$msWith = (hrtime(true) - $mulai) / 1e6;
// dengan withCount: satu subquery COUNT di SQL
$mulai = hrtime(true);
$jumlah = Category::withCount('posts')->get()->map(fn ($c) => $c->posts_count);
$msCount = (hrtime(true) - $mulai) / 1e6;
echo sprintf("with('posts'): %.1f ms\n", $msWith);
echo sprintf("withCount('posts'): %.1f ms\n", $msCount);Output yang Dihasilkan:
with('posts'): 210.0 ms
withCount('posts'): 47.0 mswithCount() menghasilkan kolom virtual posts_count lewat subquery (select count(*) from "posts" where ...) — 4,5 kali lebih cepat karena database menghitung alih-alih mengirim data ke PHP. Prinsipnya: with() untuk relasi yang isinya dirender, withCount() untuk angkanya saja.
Batasi Kolom yang Di-eager-load #
Eager load juga bisa dikurangi pakai query builder di dalam array — relasi tetap dimuat, tetapi hanya kolom yang dibutuhkan:
use App\Models\Post;
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
Post::with(['user' => fn ($q) => $q->select('id', 'name')])
->orderBy('id')->limit(2)->get()
->each(fn ($p) => $p->user->name);
foreach (DB::getQueryLog() as $q) {
echo '[', $q['query'], "]\n";
}
DB::disableQueryLog();Output yang Dihasilkan:
[select * from "posts" order by "id" asc limit 2]
[select "id", "name" from "users" where "users"."id" in (1, 2)]Query relasi kini hanya membawa id dan name — bukan password hash, timestamp, dan kolom lain milik users. Satu keharusan: kolom pengunci relasi — di sini id milik users, yang dicocokkan dengan user_id di posts — wajib ikut diseleksi, atau Eloquent tidak bisa memasangkan hasilnya.
Default $with: Selalu Dimuat, Selalu Dibayar #
Properti $with di model menjadikan relasi selalu di-eager-load di setiap query:
class Post extends Model
{
protected $with = ['user'];
}Hasilnya nyata: Post::orderBy('id')->limit(3)->get() menjalankan 2 query (posts + users) tanpa satu baris with() pun di caller. Ini nyaman untuk relasi yang memang selalu dirender — dan itu pula bahayanya: query yang tidak membutuhkan relasi tetap membayarnya, misalnya command export yang mengolah ratusan ribu baris atau endpoint yang hanya membaca kolom posts. Gunakan $with untuk relasi yang benar-benar universal, bukan sebagai pengganti kebiasaan berpikir.
N+1 yang Tersembunyi: Accessor dan Serialisasi JSON #
Pembunuh diam-diam favorit: accessor yang menyentuh relasi. Misalkan model Post punya accessor penulis seperti ini:
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function penulis(): Attribute
{
return Attribute::make(
get: fn () => $this->user->name,
);
}View jadi tampak bersih — tidak ada ->user eksplisit — sampai toJson() memanggil accessor itu untuk setiap baris:
use App\Models\Post;
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
Post::orderBy('id')->limit(50)->get()
->map(fn ($p) => $p->only(['id', 'judul', 'penulis']))->toJson();
echo 'query count: ', count(DB::getQueryLog()), "\n";
DB::flushQueryLog(); DB::disableQueryLog();
DB::enableQueryLog();
Post::with('user')->orderBy('id')->limit(50)->get()
->map(fn ($p) => $p->only(['id', 'judul', 'penulis']))->toJson();
echo 'query count (dengan with): ', count(DB::getQueryLog()), "\n";
DB::disableQueryLog();Output yang Dihasilkan:
query count: 51
query count (dengan with): 2Serialisasi 50 baris tanpa with('user') diam-diam menjalankan 51 query — accessor memicu lazy loading di balik layar. Setiap accessor, appends JSON, atau event yang menyentuh relasi adalah kandidat N+1 tersembunyi.
Mencegah Regresi: preventLazyLoading() dan Test #
Optimasi hari ini bisa dilenyapkan refactoring minggu depan. Dua benteng terakhirnya: larangan runtime dan asersi di test. Model::preventLazyLoading() mengubah lazy loading menjadi exception di lingkungan non-produksi:
use Illuminate\Database\Eloquent\Model;
Model::preventLazyLoading(! app()->isProduction());Output yang Dihasilkan:
Attempted to lazy load [user] on model [App\Models\Post] but lazy loading is disabled.Kemudian kunci jumlah query lewat feature test — DB::listen() menghitung, assertSame memastikan:
public function test_halaman_daftar_post_hanya_tiga_query(): void
{
Route::get('/daftar-post', function () {
return Post::with(['user', 'category'])->orderBy('id')->limit(50)->get()
->map(fn ($post) => $post->judul.' — '.$post->user->name);
});
Post::factory()->count(50)->create();
$jumlah = 0;
DB::listen(function () use (&$jumlah) {
$jumlah++;
});
$this->get('/daftar-post')->assertOk();
$this->assertSame(3, $jumlah, "Halaman menjalankan {$jumlah} query — kemungkinan N+1 kambuh.");
}Output yang Dihasilkan:
PASS Tests\Feature\EagerLoadingTest
✓ halaman daftar post hanya tiga query 0.19sTest ini gagal keras begitu ada yang menghapus with() dari route — regresi N+1 tertangkap sebelum menyentuh produksi, bukan setelah halaman melambat.
Dengan eager loading yang disiplin, kolom query yang meminum habis daya database sudah kita kendalikan. Tuas berikutnya bekerja bahkan sebelum baris pertama dikirim: index pada kolom WHERE dan ORDER BY, topik chapter Query Indexing
.