Relasi & masalah N+1
Satu-satunya masalah performa yang hampir pasti ada di setiap aplikasi Laravel yang belum diperiksa adalah N+1. Kabar baiknya, ia gampang dilihat dan gampang diperbaiki.
Intisari
- Relasi diakses sebagai properti (
$produk->kategori), dan itulah yang memicu query tambahan. - N+1: satu query untuk daftar, plus satu query per baris untuk relasinya.
- Obatnya
with()โ eager loading. Dua query, berapa pun jumlah barisnya. withCount()untuk menghitung tanpa memuat,withSum()untuk menjumlah.- Nyalakan
Model::preventLazyLoading()di lokal supaya N+1 menggagalkan tes, bukan cuma memperlambat.
Bentuk relasi
class Produk extends Model
{
public function kategori(): BelongsTo
{
return $this->belongsTo(Kategori::class); // produk.kategori_id โ kategori.id
}
public function ulasan(): HasMany
{
return $this->hasMany(Ulasan::class);
}
public function tag(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'produk_tag')
->withTimestamps();
}
public function gambarUtama(): HasOne
{
return $this->hasOne(Gambar::class)->latestOfMany();
}
}
| Relasi | Arti | Foreign key ada di |
|---|---|---|
belongsTo | Satu produk milik satu kategori | Tabel ini (produk) |
hasMany | Satu produk punya banyak ulasan | Tabel sana (ulasan) |
hasOne | Sama seperti hasMany, dibatasi satu | Tabel sana |
belongsToMany | Banyak-ke-banyak | Tabel pivot |
morphMany | Polimorfik โ komentar untuk banyak jenis entitas | Tabel sana, plus kolom tipe |
Masalah N+1, dilihat langsung
// Controller
$produk = Produk::latest()->take(50)->get(); // 1 query
@foreach ($produk as $p)
<li>{{ $p->nama }} โ {{ $p->kategori->nama }}</li> {{-- 1 query PER BARIS --}}
@endforeach
Total: 51 query.
select * from produk order by created_at desc limit 50
select * from kategori where id = 3 limit 1
select * from kategori where id = 7 limit 1
select * from kategori where id = 3 limit 1 โ yang sama, lagi
โฆ 47 baris berikutnya
Di laptop dengan database lokal, 51 query terasa "cukup cepat". Di produksi, dengan latensi jaringan 1โ2 ms ke RDS, itu tambahan 50โ100 ms yang bukan berasal dari kode apa pun yang bisa kamu lihat di halaman itu.
Obatnya
$produk = Produk::with('kategori')->latest()->take(50)->get();
Total: 2 query โ berapa pun jumlah barisnya.
select * from produk order by created_at desc limit 50
select * from kategori where id in (3, 7, 12, 4, โฆ)
// Bersarang dan berkondisi
Produk::with([
'kategori',
'ulasan' => fn ($q) => $q->where('disetujui', true)->latest()->limit(3),
'ulasan.penulis:id,name', // hanya kolom yang dipakai
])->get();
// Menghitung tanpa memuat isinya โ satu subquery, bukan satu query per baris
Produk::withCount('ulasan')->get(); // $p->ulasan_count
Produk::withSum('items', 'jumlah')->get(); // $p->items_sum_jumlah
Produk::withExists('ulasan')->get(); // $p->ulasan_exists
withCount() adalah perbaikan yang paling sering terlewat. Menulis
{{ $p->ulasan->count() }} di dalam loop akan memuat seluruh ulasan setiap produk hanya
untuk menghitungnya. withCount('ulasan') menghasilkan satu angka dari database dan tidak memuat
satu baris ulasan pun.
Menjadikan N+1 sebagai error
// app/Providers/AppServiceProvider.php
public function boot(): void
{
// Di lokal dan saat tes: mengakses relasi tanpa with() akan melempar exception.
Model::preventLazyLoading(! $this->app->isProduction());
// Sekalian: cegah mengisi kolom yang tidak ada di $fillable secara diam-diam
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}
Ini satu baris yang mengubah kebiasaan tim. Tanpanya, N+1 hanya "agak lambat" dan tidak pernah masuk
daftar pekerjaan. Dengannya, kode yang lupa with() akan gagal di tes โ sebelum sampai ke review,
apalagi ke produksi. Sengaja dimatikan di produksi supaya tidak menjatuhkan halaman yang terlewat.
Menyaring berdasarkan relasi
Produk::whereHas('ulasan', fn ($q) => $q->where('rating', 5))->get();
Produk::whereDoesntHave('ulasan')->get();
Produk::has('ulasan', '>=', 10)->get();
// Sering lebih murah untuk sekadar "ada atau tidak"
Produk::whereRelation('kategori', 'slug', 'minuman')->get();
Menyimpan lewat relasi
$produk->ulasan()->create(['isi' => 'Bagus', 'rating' => 5]); // foreign key otomatis
$produk->tag()->attach([1, 2]); // tambah
$produk->tag()->detach(1); // lepas satu
$produk->tag()->sync([2, 3]); // jadikan persis ini โ sisanya dilepas
$produk->tag()->syncWithoutDetaching([4]);
sync() menghapus yang tidak disebutkan. Kalau form edit hanya mengirim tag yang terlihat
di layar sementara ada tag lain yang tidak ditampilkan, sync() akan menghapusnya. Untuk penambahan
saja, pakai syncWithoutDetaching().
Alur berburu N+1
- Nyalakan
Model::preventLazyLoading()di lokal. - Jalankan suite tes โ kegagalan menunjuk langsung ke relasi dan berkasnya.
- Tambahkan
with()di query-nya, bukan di template. - Untuk hitungan, ganti dengan
withCount(). - Pasang Laravel Pulse atau Telescope (Fase 7) supaya query lambat tetap terlihat setelah rilis.
Latihan: buat 50 produk beserta kategori dan ulasan lewat factory, tampilkan daftarnya, dan hitung
query-nya dengan DB::listen(). Catat angkanya. Tambahkan with('kategori') dan
withCount('ulasan'), ukur ulang, lalu nyalakan preventLazyLoading() dan pastikan
halamannya tetap hijau.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.