Halaman daftar order sering terlihat sederhana: ambil data order, tampilkan nama customer, jumlah item, tanggal, dan total. Namun di aplikasi Laravel, halaman seperti ini sangat mudah menjadi lambat ketika relasi diakses secara naif di dalam loop. Salah satu penyebab paling umum adalah N+1 query.
Di artikel ini kita akan membahas studi kasus performa pada halaman daftar order yang lambat karena relasi customer dan items. Fokusnya bukan teori umum, tetapi gejala nyata di production, cara mendeteksi masalah tanpa menebak-nebak, dan langkah optimasi yang benar-benar berdampak. Kita akan melihat perbaikan dengan with(), loadCount(), dan select kolom seperlunya.
Gejala di Production: Halaman Order Tiba-Tiba Lambat
Masalah ini biasanya tidak langsung terlihat saat data masih sedikit. Di lingkungan development, halaman order dengan 10 data mungkin terasa normal. Tetapi setelah ada ratusan atau ribuan order, response time melonjak.
Gejala yang sering muncul:
- Halaman daftar order terasa lambat dibuka, terutama saat pagination menampilkan 50 atau 100 baris.
- CPU database naik meskipun traffic tidak terlalu tinggi.
- APM atau monitoring menunjukkan query count sangat besar pada satu request.
- Latency meningkat terutama pada endpoint yang merender tabel order.
Misalnya, controller awal terlihat sederhana seperti ini:
public function index()
{
$orders = Order::latest()->paginate(50);
return view('orders.index', compact('orders'));
}Lalu di Blade:
@foreach ($orders as $order)
<tr>
<td>{{ $order->id }}</td>
<td>{{ $order->customer->name }}</td>
<td>{{ $order->items->count() }}</td>
<td>{{ $order->created_at->format('Y-m-d H:i') }}</td>
</tr>
@endforeachSekilas kode ini wajar. Masalahnya, ketika $order->customer dan $order->items diakses di dalam loop, Eloquent bisa menjalankan query tambahan untuk setiap order.
Jika ada 50 order:
- 1 query untuk mengambil daftar order
- 50 query untuk mengambil customer
- 50 query untuk mengambil item
Totalnya bisa menjadi 101 query hanya untuk satu halaman. Inilah pola N+1.
Memahami Sumber Masalah pada Relasi Customer dan Item
Anggap model relasinya seperti ini:
class Order extends Model
{
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
}Secara default, Eloquent memakai lazy loading. Artinya relasi baru diambil saat properti relasi pertama kali diakses. Saat di dalam loop Anda memanggil $order->customer, Eloquent akan melakukan query jika relasi itu belum dimuat. Hal yang sama terjadi pada $order->items.
Masalahnya bukan pada relasi itu sendiri, tetapi pada pola akses data. Di halaman list, kita hampir selalu tahu relasi apa yang akan dipakai. Karena itu, relasi tersebut seharusnya dimuat di awal menggunakan eager loading.
Catatan penting: N+1 query sering tersembunyi di Blade, API Resource, Accessor, atau Transformer. Jadi masalahnya tidak selalu tampak jelas di controller.
Cara Mendeteksi N+1 Query dengan Praktis
1. Lihat Query Log di Development atau Staging
Cara paling langsung adalah mengaktifkan query log atau memakai listener query.
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,
]);
});Jangan biarkan logging query mentah aktif terus di production tanpa kontrol, karena volumenya besar dan bisa menambah beban. Untuk investigasi, aktifkan sementara pada endpoint tertentu atau environment staging.
Tanda N+1 biasanya mudah dikenali: query yang mirip dipanggil berulang-ulang, misalnya:
select * from `customers` where `customers`.`id` = ? limit 1
select * from `customers` where `customers`.`id` = ? limit 1
select * from `customers` where `customers`.`id` = ? limit 1Atau:
select * from `order_items` where `order_items`.`order_id` = ?yang muncul puluhan kali untuk request yang sama.
2. Gunakan Laravel Telescope Bila Tersedia
Jika proyek Anda memakai Laravel Telescope, fitur query watcher sangat membantu. Anda bisa melihat:
- Jumlah query per request
- Durasi total query
- Query yang dieksekusi berulang
- Request atau job mana yang paling boros query
Telescope sangat cocok untuk investigasi di development atau environment internal. Namun untuk production publik, penggunaannya perlu dipertimbangkan dengan hati-hati karena overhead dan keamanan akses dashboard.
Jika Telescope tidak tersedia, alternatif minimal adalah:
DB::listen()seperti contoh di atas- Monitoring APM seperti New Relic, Datadog, atau setara jika infrastruktur Anda punya
- Log sampling pada route yang dicurigai lambat
3. Ukur Jumlah Query Sebelum Optimasi
Sebelum memperbaiki, catat metrik sederhananya. Misalnya untuk halaman 50 order:
- Sebelum: sekitar 101 query
- Sesudah eager loading customer dan item count: bisa turun menjadi 2-3 query
Angka tepatnya tergantung struktur relasi dan apakah Anda memerlukan seluruh item atau hanya jumlahnya. Yang penting, penurunannya harus signifikan, bukan sekadar kosmetik.
Perbaikan Tahap 1: Gunakan with() untuk Customer
Jika halaman hanya butuh nama customer, relasi customer sebaiknya dimuat di awal.
Sebelum:
public function index()
{
$orders = Order::latest()->paginate(50);
return view('orders.index', compact('orders'));
}Sesudah:
public function index()
{
$orders = Order::with('customer')
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}Dengan perubahan ini, Laravel akan mengambil semua customer terkait dalam satu query tambahan, bukan satu query per order. Untuk 50 order, beban query customer turun dari 50 query menjadi 1 query.
Namun kita bisa lebih baik lagi: jangan ambil semua kolom customer jika hanya butuh id dan name.
public function index()
{
$orders = Order::select('id', 'customer_id', 'order_number', 'created_at', 'grand_total')
->with('customer:id,name')
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}Kenapa ini penting?
- Mengurangi data yang ditransfer dari database
- Mengurangi penggunaan memori di PHP
- Mencegah Eloquent menghidrasi kolom yang tidak dipakai
Kesalahan umum di sini adalah lupa menyertakan foreign key yang dibutuhkan. Jika Anda memakai with('customer:id,name'), model Order tetap harus mengambil customer_id, karena itu kunci untuk memasang relasi.
Perbaikan Tahap 2: Jangan Ambil Semua Item Jika Hanya Butuh Jumlahnya
Pada banyak halaman order, yang ditampilkan bukan detail semua item, melainkan hanya jumlah item per order. Di situ sering ada kesalahan berikut:
{{ $order->items->count() }}Secara visual sederhana, tetapi secara performa mahal. Kode ini memuat seluruh koleksi item per order lalu menghitungnya di memory. Untuk 50 order, itu bisa berarti 50 query tambahan, dan setiap query bisa mengambil banyak baris.
Jika yang dibutuhkan hanya jumlah item, gunakan loadCount() atau lebih praktis withCount() saat query utama dibangun.
public function index()
{
$orders = Order::select('id', 'customer_id', 'order_number', 'created_at', 'grand_total')
->with('customer:id,name')
->withCount('items')
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}Di Blade, cukup gunakan:
{{ $order->items_count }}Dengan pendekatan ini, Anda tidak perlu memuat semua item. Database akan menghitung jumlahnya, dan Eloquent menambahkan kolom items_count ke hasil query.
Jika collection order sudah terlanjur diambil dan Anda ingin menambahkan count setelahnya, Anda bisa memakai loadCount():
$orders = Order::with('customer:id,name')
->latest()
->paginate(50);
$orders->loadCount('items');Untuk kasus halaman list, withCount() biasanya lebih natural karena semuanya dilakukan dalam satu alur query. Namun loadCount() berguna ketika data sudah ada dan Anda ingin menambah count secara kondisional.
Before-After yang Lebih Realistis
Implementasi Sebelum Optimasi
public function index()
{
$orders = Order::latest()->paginate(50);
return view('orders.index', compact('orders'));
}@foreach ($orders as $order)
<tr>
<td>{{ $order->order_number }}</td>
<td>{{ $order->customer->name }}</td>
<td>{{ $order->items->count() }} item</td>
<td>{{ number_format($order->grand_total) }}</td>
</tr>
@endforeachPotensi query untuk 50 order:
- 1 query orders
- 50 query customers
- 50 query items
- Total sekitar 101 query
Implementasi Sesudah Optimasi
public function index()
{
$orders = Order::query()
->select('id', 'customer_id', 'order_number', 'grand_total', 'created_at')
->with('customer:id,name')
->withCount('items')
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}@foreach ($orders as $order)
<tr>
<td>{{ $order->order_number }}</td>
<td>{{ $order->customer?->name ?? '-' }}</td>
<td>{{ $order->items_count }} item</td>
<td>{{ number_format($order->grand_total) }}</td>
</tr>
@endforeachHasilnya biasanya turun drastis menjadi:
- 1 query orders
- 1 query customers
- 1 mekanisme count untuk items yang dibawa bersama hasil
- Total praktis sekitar 2-3 query, bukan 101 query
Selain jumlah query turun, payload data juga lebih kecil karena kita tidak mengambil semua kolom dan tidak memuat seluruh item.
Trade-off, Batasan, dan Kesalahan yang Sering Terjadi
Jangan Asal Eager Load Semua Relasi
Eager loading memang mengurangi jumlah query, tetapi bukan berarti semua relasi harus selalu dimuat. Jika Anda memuat relasi besar yang sebenarnya tidak dipakai, penggunaan memori bisa naik dan query utama menjadi berat. Prinsipnya: muat yang benar-benar diperlukan oleh tampilan atau response.
with() untuk Relasi, withCount()/loadCount() untuk Agregasi Ringan
Jika hanya butuh jumlah item, lebih efisien memakai count daripada memuat semua item. Banyak developer melakukan eager load items lalu memanggil $order->items->count(). Ini mengurangi N+1, tetapi tetap boros karena semua baris item diambil padahal hanya jumlahnya yang dipakai.
Perhatikan Accessor dan Resource
Masalah N+1 sering berpindah tempat. Misalnya ada accessor seperti:
public function getCustomerNameAttribute()
{
return $this->customer?->name;
}Lalu di view Anda memanggil $order->customer_name. Secara tampilan bersih, tetapi kalau relasi tidak di-eager load, query tambahan tetap terjadi. Hal serupa juga sering muncul di API Resource.
Paginasi Bukan Solusi Utama
Paginasi 10 item memang mengurangi dampak, tetapi tidak menyelesaikan akar masalah. N+1 tetap ada, hanya skalanya diperkecil. Saat traffic naik, masalah tetap muncul.
Checklist Debugging Saat Halaman Order Masih Lambat
- Catat jumlah query untuk satu request halaman order.
- Cari query berulang dengan pola sama pada tabel
customersatauorder_items. - Tambahkan
with()pada relasi yang dipakai di tabel. - Ganti akses count berbasis koleksi menjadi
withCount()atauloadCount(). - Batasi kolom dengan
select()danwith('relation:id,...'). - Periksa Blade, Resource, accessor, dan helper yang mungkin mengakses relasi tersembunyi.
- Bandingkan lagi jumlah query dan durasinya setelah perubahan.
Jika setelah optimasi jumlah query sudah rendah tetapi halaman tetap lambat, investigasi berikutnya biasanya mengarah ke:
- Index database yang kurang tepat
- Sorting atau filtering mahal
- Query agregasi kompleks
- Rendering view yang berat
- Network latency ke database
Namun untuk banyak kasus halaman list Laravel, N+1 adalah tersangka utama yang paling cepat memberi dampak saat diperbaiki.
Penutup
Dalam studi kasus halaman daftar order Laravel, masalah performa bukan berasal dari fitur yang rumit, melainkan dari pola akses relasi yang tampak sepele: mengambil customer dan item di dalam loop. Akibatnya, satu halaman bisa mengeksekusi puluhan hingga ratusan query.
Perbaikannya juga tidak harus rumit. Dengan with() untuk relasi customer, withCount() atau loadCount() untuk jumlah item, serta select kolom yang benar-benar dibutuhkan, jumlah query bisa turun drastis dari sekitar 101 menjadi hanya beberapa query saja untuk 50 baris data.
Intinya, optimasi Laravel yang efektif bukan sekadar “pakai eager loading”, tetapi memuat data yang tepat, dalam jumlah yang tepat, pada waktu yang tepat. Itulah yang membuat halaman order kembali responsif tanpa mengubah arsitektur aplikasi secara besar-besaran.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!