N+1 query di Laravel sering terlihat sepele saat data masih sedikit, tetapi di production dampaknya cepat terasa: response time naik, jumlah query membengkak, CPU database tinggi, dan pagination menjadi lambat. Kasus ini umum terjadi pada halaman daftar order ketika setiap baris mencoba mengakses relasi customer dan items secara terpisah di dalam loop.

Pada artikel ini, kita bahas postmortem bug nyata di halaman daftar order: bagaimana gejalanya muncul, apa root cause teknisnya, cara mereproduksi dan mengidentifikasi masalah lewat log SQL, Laravel Debugbar atau Telescope, serta bagaimana memperbaikinya dengan eager loading, pemilihan kolom seperlunya, dan disiplin menghindari akses relasi di loop.

Studi kasus: gejala di production

Halaman yang bermasalah adalah daftar order dengan pagination. Setiap order menampilkan informasi berikut:

  • Nomor order
  • Nama customer
  • Jumlah item
  • Total harga
  • Tanggal order

Secara fungsional, halaman bekerja normal. Namun setelah volume data bertambah, tim mulai melihat gejala berikut di production:

  • Response time meningkat terutama pada endpoint daftar order.
  • Jumlah query SQL melonjak saat membuka satu halaman pagination.
  • CPU database tinggi walau traffic tidak naik drastis.
  • Pagination terasa lambat, terutama saat pindah halaman atau memakai filter.

Gejala ini khas untuk kasus ketika aplikasi mengeksekusi banyak query kecil yang sebenarnya bisa digabung menjadi beberapa query saja.

Root cause teknis N+1 query di Laravel

N+1 query terjadi ketika aplikasi mengambil daftar data utama dengan 1 query, lalu untuk setiap baris data menjalankan query tambahan untuk mengambil relasi. Jika ada 50 order dalam satu halaman, dan setiap order memicu query customer dan query items, maka total query bisa bertambah sangat cepat.

Contoh pola masalah:

  • 1 query untuk mengambil 50 order
  • 50 query untuk mengambil customer tiap order
  • 50 query untuk mengambil items tiap order

Totalnya menjadi 101 query hanya untuk merender satu halaman. Jika di dalam loop ada perhitungan lain berbasis relasi, jumlah query bisa lebih besar lagi.

Di Laravel, ini biasanya muncul karena lazy loading: relasi baru diambil saat properti relasi diakses, misalnya $order->customer atau $order->items. Lazy loading tidak salah dalam semua kasus, tetapi menjadi masalah pada halaman list dengan banyak baris.

Contoh kode sebelum perbaikan

Berikut contoh controller dan Blade yang tampak wajar, tetapi berpotensi menimbulkan N+1 query:

// Controller
public function index()
{
    $orders = Order::query()
        ->latest()
        ->paginate(50);

    return view('orders.index', compact('orders'));
}
<!-- Blade -->
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->order_number }}</td>
        <td>{{ $order->customer->name }}</td>
        <td>{{ $order->items->count() }}</td>
        <td>{{ number_format($order->items->sum('price')) }}</td>
        <td>{{ $order->created_at }}</td>
    </tr>
@endforeach

Masalah teknis di sini:

  • $order->customer memicu query jika relasi belum dimuat.
  • $order->items juga memicu query per order.
  • Pemanggilan count() dan sum() pada collection relasi yang baru dimuat memperbesar biaya memori dan CPU aplikasi.
  • Jika relasi tidak dibatasi kolomnya, aplikasi mengambil data lebih banyak dari yang dibutuhkan.

Cara mereproduksi bug secara lokal

Agar debugging akurat, reproduksi masalah dengan kondisi yang mendekati production:

  1. Buat data order dalam jumlah cukup, misalnya beberapa ratus atau ribuan baris.
  2. Pastikan setiap order punya relasi customer dan beberapa items.
  3. Buka halaman daftar order dengan pagination 50 atau 100 item per halaman.
  4. Amati query yang berjalan saat view dirender.

Jika data lokal terlalu kecil, N+1 kadang tidak terlihat signifikan. Masalahnya baru terasa ketika satu halaman berisi cukup banyak relasi yang diakses berulang.

Dataset yang perlu disiapkan

Untuk kasus ini, pola data yang relevan adalah:

  • Satu order belongsTo satu customer.
  • Satu order hasMany items.
  • Halaman list menampilkan field dari order, customer, dan ringkasan items.

Fokusnya bukan pada ukuran tabel semata, melainkan pada banyaknya akses relasi per halaman.

Mengidentifikasi masalah: log SQL, Debugbar, Telescope, dan EXPLAIN

1. Log SQL untuk melihat pola query berulang

Cara paling langsung adalah merekam query SQL yang dijalankan saat endpoint dipanggil. Tujuannya bukan sekadar menghitung jumlah query, tetapi mencari pola query yang sama dieksekusi berulang dengan parameter berbeda.

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function ($query) {
    Log::debug('SQL', [
        'sql' => $query->sql,
        'bindings' => $query->bindings,
        'time_ms' => $query->time,
    ]);
});

Indikasi N+1 biasanya terlihat seperti ini:

  • Satu query select * from orders ... limit ... offset ...
  • Lalu berkali-kali query select * from customers where id = ? limit 1
  • Lalu berkali-kali query select * from order_items where order_id = ?

Jika query yang sama muncul puluhan kali dengan binding berbeda, hampir pasti ada akses relasi di dalam loop.

2. Laravel Debugbar untuk debugging lokal

Laravel Debugbar berguna di local environment karena langsung menampilkan total query, durasi query, dan duplikasi query dalam satu request. Untuk halaman daftar order, Anda bisa membuka tab query dan melihat apakah ada query berulang terhadap tabel customers atau order_items.

Pilih Debugbar ketika:

  • Masalah mudah direproduksi di lokal.
  • Anda ingin melihat query per request dengan cepat.
  • Anda butuh indikasi visual soal duplikasi query.

Gunakan Debugbar hanya di lingkungan pengembangan. Jangan mengaktifkannya di production.

3. Laravel Telescope untuk inspeksi request

Telescope cocok saat Anda ingin menelusuri request tertentu dan melihat query yang terkait secara lebih terstruktur. Ini berguna di environment non-production yang mirip staging, atau saat tim ingin membandingkan sebelum dan sesudah perbaikan tanpa menambahkan logging manual.

Pilih Telescope ketika:

  • Anda perlu histori request dan query.
  • Ingin memeriksa endpoint tertentu secara berulang.
  • Butuh visibilitas debugging yang lebih lengkap daripada Debugbar.

4. EXPLAIN untuk memastikan query utama efisien

Setelah N+1 diatasi, jangan berhenti di sana. Query utama dan query eager loading tetap perlu dicek dengan EXPLAIN secara ringkas untuk memastikan database tidak melakukan full scan yang tidak perlu.

Misalnya, cek query order yang dipakai untuk pagination dan query relasi yang mengambil customer atau items berdasarkan foreign key. Hal yang dilihat secara umum:

  • Apakah filter dan sorting memakai index yang sesuai.
  • Apakah relasi menggunakan kolom foreign key yang terindeks.
  • Apakah jumlah row yang dipindai masuk akal.

EXPLAIN tidak dipakai untuk mendeteksi N+1 secara langsung, melainkan untuk memastikan query hasil perbaikan tetap sehat di level database.

Perbaikan: eager loading, select kolom seperlunya, dan hindari akses relasi di loop

1. Eager loading relasi yang dipakai

Perbaikan pertama adalah memuat relasi yang memang dibutuhkan sejak awal dengan with(). Ini mengubah banyak query kecil menjadi beberapa query yang lebih terkontrol.

// Controller
public function index()
{
    $orders = Order::query()
        ->select(['id', 'order_number', 'customer_id', 'created_at'])
        ->with([
            'customer:id,name',
            'items:id,order_id,price'
        ])
        ->latest()
        ->paginate(50);

    return view('orders.index', compact('orders'));
}

Kenapa ini bekerja:

  • Laravel mengambil order terlebih dahulu.
  • Lalu mengambil semua customer yang relevan dalam satu query terpisah.
  • Lalu mengambil semua items untuk order di halaman tersebut dalam satu query terpisah.

Dengan pola ini, jumlah query turun drastis dibanding memuat relasi satu per satu di dalam loop.

2. Pilih kolom seperlunya

select() pada query utama dan pembatasan kolom pada with() membantu mengurangi data yang ditransfer dan di-hydrate menjadi model Eloquent. Ini penting jika tabel punya banyak kolom yang tidak dipakai di halaman list.

Namun ada satu aturan penting: saat membatasi kolom relasi, jangan lupa menyertakan primary key dan foreign key yang dibutuhkan untuk memetakan relasi. Contohnya:

  • customer:id,name perlu kolom id
  • items:id,order_id,price perlu kolom id dan order_id

Kesalahan umum adalah memilih kolom terlalu agresif hingga relasi gagal dipetakan dengan benar.

3. Hindari perhitungan relasi mahal di Blade

Meski relasi sudah di-eager load, tetap hindari logika berat di view. Untuk kasus jumlah item, lebih efisien memakai agregasi di query daripada menghitung dari collection di Blade.

Contoh perbaikan yang lebih baik:

// Controller
public function index()
{
    $orders = Order::query()
        ->select(['id', 'order_number', 'customer_id', 'created_at', 'grand_total'])
        ->with(['customer:id,name'])
        ->withCount('items')
        ->latest()
        ->paginate(50);

    return view('orders.index', compact('orders'));
}
<!-- Blade -->
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->order_number }}</td>
        <td>{{ $order->customer?->name ?? '-' }}</td>
        <td>{{ $order->items_count }}</td>
        <td>{{ number_format($order->grand_total) }}</td>
        <td>{{ $order->created_at }}</td>
    </tr>
@endforeach

Jika total harga sudah tersedia di kolom order, lebih baik gunakan kolom tersebut daripada menjumlahkan ulang item di setiap request list. Bila belum ada, pertimbangkan:

  • Menghitung total saat order dibentuk atau diupdate, lalu menyimpannya di order.
  • Menggunakan agregasi query yang eksplisit bila memang perlu ditampilkan.

Intinya, halaman list sebaiknya tidak melakukan komputasi berat berbasis relasi untuk setiap baris.

4. Cegah akses relasi tersembunyi di accessor atau serializer

Bug N+1 tidak selalu terlihat di Blade. Kadang sumbernya ada di accessor, resource, atau transformasi JSON yang diam-diam mengakses relasi.

public function getCustomerNameAttribute()
{
    return $this->customer->name;
}

Accessor seperti ini tampak rapi, tetapi jika dipanggil untuk banyak order tanpa eager loading, hasilnya sama saja: query customer dieksekusi berulang. Karena itu, periksa juga:

  • Accessor model
  • API Resource
  • Transformer
  • Presenter atau helper view

Perbandingan sebelum dan sesudah perbaikan

Sebelum

  • Query order dipanggil sekali.
  • Customer di-load per order.
  • Items di-load per order.
  • View melakukan count() dan sum() dari relasi.
  • Pagination melambat seiring jumlah data di halaman.

Sesudah

  • Order diambil dengan kolom yang memang dipakai.
  • Customer dimuat lewat eager loading.
  • Jumlah item diambil lewat withCount().
  • Total memakai nilai yang sudah tersedia atau agregasi yang lebih tepat.
  • View hanya merender data, bukan memicu query tambahan.

Tujuan utama perbaikan bukan hanya mengurangi angka query, tetapi membuat pola akses data menjadi eksplisit dan terprediksi.

Langkah verifikasi setelah fix

Setelah perubahan dilakukan, verifikasi jangan hanya berdasarkan “terasa lebih cepat”. Gunakan checklist yang terukur:

  1. Hitung ulang jumlah query pada endpoint daftar order.
  2. Bandingkan pola SQL: query berulang terhadap customer dan items harus hilang.
  3. Cek waktu response di environment yang representatif.
  4. Amati CPU database dan metrik query jika ada monitoring.
  5. Uji pagination pada beberapa halaman, bukan hanya halaman pertama.
  6. Uji filter dan sorting karena kombinasi query bisa berubah.

Jika memungkinkan, tambahkan pengujian atau assertion sederhana untuk mencegah regresi. Minimal, dokumentasikan ekspektasi bahwa endpoint list tidak boleh mengakses relasi secara lazy di loop.

Apa yang perlu terlihat setelah perbaikan

  • Jumlah query turun signifikan.
  • Tidak ada lagi pola query relasi yang berulang per row.
  • Waktu render halaman lebih stabil saat jumlah item per halaman bertambah.
  • Beban database menurun karena jumlah round-trip berkurang.

Trade-off dan batasan eager loading

Eager loading adalah solusi utama untuk N+1, tetapi tetap punya trade-off:

  • Memori aplikasi bisa naik jika terlalu banyak relasi dimuat sekaligus.
  • Data berlebih tetap mungkin terjadi jika semua kolom diambil tanpa seleksi.
  • Tidak semua kebutuhan cocok dengan Eloquent penuh; untuk list kompleks, query builder atau agregasi terpisah kadang lebih efisien.

Karena itu, gunakan eager loading secara terarah:

  • Muat hanya relasi yang benar-benar dipakai.
  • Batasi kolom seperlunya.
  • Pakai withCount() atau agregasi bila yang dibutuhkan hanya ringkasan.
  • Hindari memuat relasi besar yang tidak ditampilkan.

Untuk halaman daftar, kebutuhan umumnya adalah data ringkas. Jangan perlakukan halaman list seperti halaman detail.

Checklist debugging N+1 query di Laravel

  • Apakah ada akses relasi di dalam foreach?
  • Apakah accessor, resource, atau serializer mengakses relasi secara implisit?
  • Apakah relasi yang dipakai sudah di-with()?
  • Apakah hanya kolom yang dibutuhkan yang diambil?
  • Apakah jumlah item atau agregat lain sebaiknya memakai withCount() atau data terdenormalisasi?
  • Apakah query utama dan query relasi sudah dicek dengan EXPLAIN?
  • Apakah foreign key yang dipakai join atau filter memiliki index yang sesuai?
  • Apakah pagination diuji pada data yang cukup besar?

Pelajaran agar bug serupa tidak terulang

1. Perlakukan halaman list sebagai jalur kritis performa

Endpoint list biasanya paling sering dipanggil dan paling mudah terkena N+1. Saat menambah kolom baru ke tabel list, selalu cek apakah ada akses relasi tambahan.

2. Review query, bukan hanya kode PHP

Kode Eloquent bisa terlihat bersih, tetapi performanya ditentukan oleh query yang dihasilkan. Biasakan melihat SQL saat mengubah endpoint yang memuat relasi.

3. Pisahkan kebutuhan halaman detail dan halaman list

Halaman detail boleh memuat relasi lebih banyak. Halaman list sebaiknya ringkas, fokus pada kolom inti dan agregasi yang ringan.

4. Tambahkan guardrail di tim

Beberapa praktik yang membantu:

  • Checklist review untuk akses relasi di loop
  • Penggunaan tool observability untuk memantau query per request
  • Konvensi bahwa accessor tidak boleh mengakses relasi tanpa alasan kuat
  • Pengujian performa sederhana pada endpoint list penting

Penutup

Dalam postmortem ini, akar masalah bukan pada pagination itu sendiri, melainkan pada N+1 query di Laravel yang tersembunyi saat halaman daftar order mengakses relasi customer dan item per baris. Gejalanya muncul sebagai response time yang naik, query membengkak, CPU database tinggi, dan pagination yang terasa lambat.

Perbaikannya relatif jelas setelah akar masalah ditemukan: gunakan eager loading, ambil kolom seperlunya, hindari akses relasi di loop, dan pindahkan agregasi ke level query atau data yang sudah dipersiapkan sebelumnya. Yang paling penting, jadikan inspeksi query sebagai bagian dari kebiasaan engineering, terutama untuk endpoint list yang dipakai setiap hari.