โ† Semua pembelajaran / Laravel Nol โ†’ Enterprise
Fase 2 ยท Database & Eloquent

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.

Sumber asli laravel.com Resmi Rangkuman ~9 menit baca

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();
    }
}
RelasiArtiForeign key ada di
belongsToSatu produk milik satu kategoriTabel ini (produk)
hasManySatu produk punya banyak ulasanTabel sana (ulasan)
hasOneSama seperti hasMany, dibatasi satuTabel sana
belongsToManyBanyak-ke-banyakTabel pivot
morphManyPolimorfik โ€” komentar untuk banyak jenis entitasTabel 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

  1. Nyalakan Model::preventLazyLoading() di lokal.
  2. Jalankan suite tes โ€” kegagalan menunjuk langsung ke relasi dan berkasnya.
  3. Tambahkan with() di query-nya, bukan di template.
  4. Untuk hitungan, ganti dengan withCount().
  5. 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.