Daftar Isi
Aplikasi Laravel yang lambat hampir selalu bukan karena "Laravel-nya lambat" — hampir selalu karena cara query Eloquent ditulis. Kami sudah melakukan puluhan code review untuk proyek klien, dan pola-pola berikut ini muncul berulang kali, bahkan di tim yang sudah berpengalaman. Artikel ini membahas 7 pola paling umum, kenapa masing-masing bikin aplikasi lambat, dan cara memperbaikinya dengan contoh kode yang bisa langsung Anda pakai.
1. N+1 Query Problem
Ini penyebab lambat nomor satu yang paling sering luput dari perhatian karena kodenya terlihat baik-baik saja saat ditulis, dan baru terasa lambat setelah data production bertambah banyak.
// ❌ Buruk: 1 query untuk posts, lalu 1 query TAMBAHAN untuk tiap post
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // query baru setiap iterasi!
}
// Kalau ada 100 post = 101 query total
// Kalau ada 10.000 post = 10.001 query total (!)
Solusinya adalah eager loading dengan with(), yang menggabungkan semua relasi jadi satu query tambahan saja, berapa pun jumlah baris datanya:
// ✅ Baik: cuma 2 query total, berapa pun jumlah post-nya
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // sudah di-preload, tidak ada query baru
}
N+1 juga bisa terjadi di relasi bersarang (nested). Untuk kasus ini, gunakan dot notation:
// Memuat relasi 2 tingkat sekaligus: post -> author -> country
$posts = Post::with('author.country')->get();
// Memuat beberapa relasi berbeda dalam satu pemanggilan
$posts = Post::with(['author', 'comments', 'tags'])->get();
Tips: install packagebarryvdh/laravel-debugbarsaat development. Kalau lihat jumlah query melonjak sebanding dengan jumlah baris data yang ditampilkan, itu tanda pasti N+1. Anda juga bisa memanggilModel::preventLazyLoading()diAppServiceProvideragar Laravel melempar exception setiap kali lazy loading terjadi selama development — cara paling efektif menangkap N+1 sebelum sempat masuk ke production.
2. Mengambil Semua Kolom Padahal Cuma Butuh Beberapa
// ❌ Menarik semua kolom, termasuk kolom besar yang tidak dipakai
$users = User::all();
// ✅ Ambil kolom yang benar-benar dibutuhkan saja
$users = User::select('id', 'name', 'email')->get();
Dampaknya makin terasa kalau tabel punya kolom besar seperti TEXT, LONGTEXT, atau JSON yang tidak selalu dipakai di halaman tersebut — setiap baris jadi lebih berat ditransfer dari database ke aplikasi, dan lebih berat lagi disimpan di memori PHP kalau jumlah barisnya banyak.
Aturan ini juga berlaku untuk eager loading — jangan tarik seluruh kolom relasi kalau cuma butuh satu-dua kolom saja:
// ✅ Batasi kolom yang diambil dari relasi juga
$posts = Post::with('author:id,name')->get();
3. Loop Sambil Insert/Update Satu per Satu
// ❌ 500 produk = 500 query INSERT terpisah, masing-masing bolak-balik ke database
foreach ($products as $product) {
Product::create($product);
}
// ✅ Satu query INSERT untuk semua data sekaligus
Product::insert($products);
Perlu diingat, insert() adalah query builder murni — dia tidak menjalankan event Eloquent seperti creating atau created, dan tidak mengisi created_at/updated_at secara otomatis kecuali Anda sertakan manual. Kalau Anda butuh event model tetap jalan, pertimbangkan insertOrIgnore() atau tetap loop tapi bungkus dalam transaction supaya prosesnya jauh lebih cepat:
DB::transaction(function () use ($products) {
foreach ($products as $product) {
Product::create($product);
}
});
// Masih N query, tapi jauh lebih cepat karena tidak ada commit
// terpisah untuk tiap query — semua di-commit sekali di akhir.
4. Memuat Seluruh Tabel ke Memori untuk Diproses
// ❌ Kalau tabel punya 1 juta baris, ini bisa bikin memory habis (fatal error)
$allOrders = Order::all();
foreach ($allOrders as $order) {
// proses satu-satu
}
// ✅ chunk() memproses per-batch tanpa menyimpan semuanya di memori
Order::chunk(500, function ($orders) {
foreach ($orders as $order) {
// proses per-batch, 500 baris sekaligus
}
});
Untuk kasus yang benar-benar butuh iterasi satu per satu dengan penggunaan memori paling rendah, cursor() adalah pilihan yang lebih hemat lagi karena menggunakan PHP generator — hanya satu baris yang ada di memori pada satu waktu:
foreach (Order::where('status', 'pending')->cursor() as $order) {
// setiap $order dimuat satu per satu, memori tetap ringan
}
Catatan penting: chunk() tidak aman dipakai bersamaan dengan update() atau delete() pada kolom yang sama dengan kondisi where-nya, karena bisa membuat baris "melompat" antar-batch. Untuk kasus itu, gunakan chunkById() yang mengurutkan berdasarkan primary key.
5. Menghitung Jumlah Data dengan count() Setelah get()
// ❌ Menarik SEMUA data ke aplikasi, baru dihitung jumlahnya di PHP
$total = Post::where('status', 'published')->get()->count();
// ✅ Hitung langsung di level database, tidak perlu tarik datanya sama sekali
$total = Post::where('status', 'published')->count();
Perbedaannya terlihat sepele di tabel kecil, tapi di tabel dengan ratusan ribu baris, versi pertama bisa memakan waktu berkali-kali lipat lebih lama karena mentransfer seluruh data yang sebenarnya tidak pernah dipakai — hanya jumlahnya saja yang dibutuhkan.
6. Tidak Memakai Index pada Kolom yang Sering Dipakai di WHERE / JOIN
Ini bukan soal Eloquent, tapi soal struktur tabelnya — dan sering jadi penyebab query lambat yang tidak kelihatan dari kode PHP sama sekali.
// Migration tanpa index — query WHERE di kolom ini akan full table scan
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
$table->foreignId('user_id');
$table->timestamps();
});
// ✅ Tambahkan index di kolom yang sering dipakai untuk filter/join
Schema::table('orders', function (Blueprint $table) {
$table->index('status');
$table->index('user_id'); // foreignId biasanya sudah otomatis diindeks,
// tapi selalu cek dengan `SHOW INDEX FROM orders;`
});
Gunakan EXPLAIN di query mentah untuk memastikan MySQL benar-benar memakai index yang sudah Anda buat, bukan tetap melakukan full scan:
DB::select('EXPLAIN SELECT * FROM orders WHERE status = ?', ['pending']);
// Perhatikan kolom `type` di hasilnya — hindari nilai `ALL` (artinya full scan)
7. Query Berulang untuk Data yang Jarang Berubah
Data seperti daftar kategori, pengaturan situs, atau daftar negara sering di-query ulang di setiap request padahal isinya nyaris tidak pernah berubah dalam hitungan menit atau jam.
// ❌ Query yang sama dijalankan ulang di setiap request
$categories = Category::orderBy('name')->get();
// ✅ Cache hasilnya, refresh otomatis setelah durasi tertentu
$categories = Cache::remember('categories.all', now()->addHour(), function () {
return Category::orderBy('name')->get();
});
Jangan lupa hapus cache-nya (Cache::forget('categories.all')) setiap kali data kategori diubah lewat admin, supaya pengguna tidak melihat data basi.
Cara Mendeteksi Masalah Ini di Proyek Anda
- Pasang Laravel Debugbar di environment development untuk melihat jumlah dan durasi query di tiap halaman secara langsung.
- Aktifkan slow query log di MySQL (
long_query_time) untuk menemukan query yang benar-benar lambat di production, bukan cuma dugaan. - Gunakan
DB::listen()diAppServiceProvideruntuk mencatat setiap query beserta waktu eksekusinya ke log selama proses debugging. - Biasakan review query di setiap fitur baru sebelum merge — bukan menunggu sampai user melapor aplikasi lemot.
Kesimpulan
Ketujuh pola ini bukan teknik optimasi tingkat lanjut — ini kebiasaan dasar yang seharusnya jadi refleks setiap developer Laravel, sama seperti kebiasaan menulis migration atau validasi. Cek ulang query di fitur yang sedang Anda kerjakan sekarang; ada peluang besar setidaknya satu dari tujuh pola ini bersembunyi di sana, menunggu ditemukan sebelum user Anda yang menemukannya lebih dulu lewat keluhan aplikasi lemot.