Halaman daftar data di Laravel sering terasa cepat saat tabel masih kecil, lalu mendadak lambat ketika jumlah baris bertambah. Pola yang paling sering terjadi adalah query Eloquent dengan kombinasi WHERE, ORDER BY, dan pagination tidak lagi efisien karena database tidak punya composite index yang sesuai dengan bentuk query.

Masalahnya bukan sekadar “belum ada index”, tetapi index yang ada tidak cocok dengan pola akses data. Single-column index pada tiap kolom sering terlihat benar di atas kertas, tetapi gagal membantu saat query memfilter beberapa kolom sekaligus lalu mengurutkan hasil. Di artikel ini, kita fokus pada cara mendiagnosis gejala tersebut di Laravel, membaca EXPLAIN secara dasar, lalu memilih composite index yang lebih tepat tanpa membuat klaim benchmark yang tidak bisa digeneralisasi.

Gejala query lambat pada halaman daftar/filter

Kasus yang umum adalah halaman admin atau dashboard yang menampilkan data dengan filter status, tenant, tanggal, lalu diurutkan berdasarkan waktu terbaru dan ditampilkan per halaman. Contohnya:

  • Daftar pesanan per toko atau tenant
  • Daftar invoice dengan filter status
  • Daftar aktivitas pengguna dengan rentang waktu
  • Daftar tiket support dengan sorting terbaru

Gejala yang sering muncul:

  • Query terasa cepat saat data ribuan baris, lalu lambat saat ratusan ribu atau jutaan baris
  • Pagination halaman awal masih masuk akal, tetapi makin berat pada halaman berikutnya
  • CPU database naik saat filter tertentu dipakai
  • Index sudah ada per kolom, tetapi planner tetap melakukan scan besar atau sort tambahan
  • Response endpoint daftar data lambat meski relasi Eloquent sudah dioptimalkan

Dalam banyak kasus, bottleneck utamanya bukan N+1 query, tetapi query utama daftar data itu sendiri.

Contoh kasus nyata: query Eloquent yang melambat

Misalkan Anda punya tabel orders untuk aplikasi multi-tenant. Halaman daftar pesanan memfilter berdasarkan tenant, status, lalu mengurutkan data terbaru:

Schema::create('orders', function ($table) {
    $table->id();
    $table->unsignedBigInteger('tenant_id');
    $table->string('status', 30);
    $table->timestamp('created_at')->nullable();
    $table->decimal('total_amount', 12, 2)->default(0);
    $table->unsignedBigInteger('customer_id')->nullable();
    $table->timestamps();
});

Query Eloquent yang umum:

$orders = Order::query()
    ->where('tenant_id', $tenantId)
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->paginate(50);

SQL yang kira-kira dihasilkan:

SELECT *
FROM orders
WHERE tenant_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT 50 OFFSET 0;

Sekilas query ini sederhana. Namun ketika tabel membesar, database harus menjawab tiga kebutuhan sekaligus:

  1. Menyaring berdasarkan tenant_id
  2. Menyaring lagi berdasarkan status
  3. Mengembalikan hasil dalam urutan created_at DESC

Jika Anda hanya membuat index seperti ini:

$table->index('tenant_id');
$table->index('status');
$table->index('created_at');

database belum tentu bisa menjalankan query secara efisien. Planner mungkin hanya memakai salah satu index, atau menggabungkan strategi yang tetap mahal, lalu masih perlu melakukan sort di luar index.

Mengapa single-column index sering gagal

Kesalahan yang umum adalah menganggap “semua kolom yang dipakai query sudah di-index, berarti aman”. Pada praktiknya, itu belum cukup.

Masalah utama: query butuh kombinasi, bukan kolom tunggal

Query di atas tidak mencari seluruh baris dengan tenant_id tertentu saja. Query mencari:

  • tenant_id = X
  • status = 'paid'
  • hasilnya harus sudah terurut berdasarkan created_at DESC

Single-column index pada tenant_id hanya membantu menyempitkan kandidat berdasarkan tenant. Setelah itu, database masih mungkin harus memeriksa banyak baris untuk status dan melakukan sort untuk created_at.

Single-column index pada status juga sering kurang berguna jika nilainya sedikit dan distribusinya tidak selektif. Misalnya status hanya terdiri dari pending, paid, cancelled. Jika sebagian besar data adalah paid, maka index ini tidak banyak membantu karena hasil pencarian tetap sangat banyak.

Single-column index pada created_at mungkin membantu sorting, tetapi tidak otomatis efisien untuk kondisi filter gabungan. Database bisa saja memilih index untuk sorting namun tetap membaca terlalu banyak baris yang nantinya dibuang.

Urutan kolom dalam index memengaruhi hasil

Composite index bekerja berdasarkan urutan kolom. Index (tenant_id, status, created_at) berbeda secara praktis dari (created_at, tenant_id, status). Yang pertama cocok untuk query yang memfilter tenant dan status, lalu mengurutkan berdasarkan waktu. Yang kedua belum tentu berguna untuk pola query yang sama.

Intinya, index yang baik mengikuti pola akses query yang paling sering, bukan sekadar daftar kolom yang muncul di query.

Cara mendiagnosis dengan EXPLAIN secara dasar

Sebelum menambah index baru, lihat dulu bagaimana database mengeksekusi query. Anda bisa mengambil SQL dari Laravel lalu menjalankan EXPLAIN di database.

Mengambil SQL dari Laravel

Untuk inspeksi cepat, Anda bisa melihat SQL dan binding:

$query = Order::query()
    ->where('tenant_id', $tenantId)
    ->where('status', 'paid')
    ->orderByDesc('created_at');

$sql = $query->toSql();
$bindings = $query->getBindings();

Atau gunakan query log / Laravel Debugbar / Telescope bila tersedia di lingkungan pengembangan. Tujuannya bukan alatnya, tetapi mendapatkan query SQL aktual yang berjalan.

Menjalankan EXPLAIN

Setelah mendapatkan SQL final, jalankan versi EXPLAIN-nya di database Anda. Bentuk output tergantung engine, tetapi beberapa hal dasar yang perlu diperhatikan biasanya serupa:

  • Index mana yang dipakai
  • Berapa banyak baris yang diperkirakan dibaca
  • Apakah ada sort tambahan
  • Apakah filter dikerjakan lewat index atau setelah membaca data

Anda tidak perlu menghafal seluruh kolom output EXPLAIN. Untuk kasus daftar/filter, fokus pada pertanyaan ini:

  1. Apakah planner memakai index yang relevan?
  2. Apakah jumlah baris yang dipindai masih besar?
  3. Apakah ada indikasi sorting yang tidak memanfaatkan index?

Tanda-tanda index belum tepat

  • Planner memilih hanya index tenant_id lalu tetap membaca banyak baris
  • Planner memilih index status yang sebenarnya tidak selektif
  • Masih ada operasi sort terpisah untuk ORDER BY created_at
  • Perkiraan rows tetap tinggi walau filter terlihat spesifik

Kalau output menunjukkan database masih membaca banyak kandidat lalu menyortir hasilnya, itu tanda bahwa composite index yang sesuai kemungkinan dibutuhkan.

Memilih composite index yang sesuai pola query

Untuk query ini:

SELECT *
FROM orders
WHERE tenant_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT 50 OFFSET 0;

pilihan yang umumnya paling masuk akal adalah composite index:

(tenant_id, status, created_at)

Mengapa urutan ini sering tepat?

  1. tenant_id dipakai sebagai filter equality dan biasanya sangat penting pada aplikasi multi-tenant
  2. status juga dipakai sebagai filter equality
  3. created_at dipakai untuk urutan hasil setelah subset data dipersempit oleh dua filter sebelumnya

Dengan urutan seperti itu, database memiliki peluang lebih besar untuk:

  • Masuk ke subset data yang relevan lebih cepat
  • Mengambil baris dalam urutan yang sudah sesuai ORDER BY
  • Mengurangi kebutuhan sort tambahan
  • Mengoptimalkan pengambilan halaman awal dengan LIMIT

Kapan urutan index perlu berbeda?

Tidak ada satu urutan yang selalu benar untuk semua query. Misalnya:

  • Jika query paling sering adalah WHERE tenant_id = ? ORDER BY created_at DESC, maka index (tenant_id, created_at) bisa lebih relevan
  • Jika filter status jarang dipakai, menaruhnya di tengah bisa membuat index kurang optimal untuk query lain
  • Jika Anda punya banyak variasi query, mungkin perlu memilih index yang paling menguntungkan untuk pola akses paling kritis, bukan mencoba memuaskan semua variasi sekaligus

Karena itu, desain index sebaiknya dimulai dari query nyata yang paling sering dan paling mahal, bukan dari struktur tabel semata.

Implementasi composite index di migration Laravel

Di Laravel, composite index bisa ditambahkan lewat migration. Contoh:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(
                ['tenant_id', 'status', 'created_at'],
                'orders_tenant_status_created_at_idx'
            );
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex('orders_tenant_status_created_at_idx');
        });
    }
};

Setelah migration dijalankan, ulangi pemeriksaan dengan EXPLAIN pada query yang sama. Jangan langsung menghapus index lama sebelum Anda yakin index tersebut memang tidak dipakai lagi oleh query lain.

Apakah perlu menghapus single-column index lama?

Jawabannya: tergantung. Jika index lama masih dipakai oleh query lain, biarkan. Jika ternyata redundant dan hanya menambah biaya write, pertimbangkan untuk menghapusnya setelah verifikasi.

Yang perlu diingat, setiap index tambahan:

  • memakan ruang penyimpanan
  • menambah biaya saat INSERT, UPDATE, dan DELETE
  • bisa membingungkan planner jika terlalu banyak pilihan yang mirip

Contoh perbaikan query Eloquent dan batasannya

Untuk kasus dasar, Eloquent-nya mungkin tidak perlu diubah sama sekali. Yang berubah adalah index di database:

$orders = Order::query()
    ->where('tenant_id', $tenantId)
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->paginate(50);

Ini poin penting: kadang query aplikasi sudah benar, tetapi struktur index belum mendukung.

Pilih kolom yang diambil bila memungkinkan

Kalau halaman daftar tidak butuh semua kolom, hindari SELECT *. Ambil hanya kolom yang diperlukan:

$orders = Order::query()
    ->select(['id', 'tenant_id', 'status', 'created_at', 'total_amount'])
    ->where('tenant_id', $tenantId)
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->paginate(50);

Ini tidak menggantikan index yang tepat, tetapi bisa mengurangi beban I/O dan payload data.

Offset pagination tetap punya biaya

Walau composite index membantu, pagination berbasis OFFSET tetap bisa mahal pada halaman yang sangat jauh karena database harus melewati sejumlah baris sebelum mengambil hasil. Untuk tabel yang sangat besar, pertimbangkan cursor pagination atau pola keyset pagination jika sesuai dengan kebutuhan UX dan konsistensi data.

Namun untuk banyak kasus Laravel sehari-hari, memperbaiki index dulu biasanya memberi dampak paling nyata sebelum mengganti strategi pagination.

Trade-off yang perlu dipahami

1. Write cost meningkat

Setiap index harus diperbarui saat data berubah. Tabel dengan trafik tulis tinggi akan menanggung biaya tambahan saat insert dan update. Jadi jangan menambah composite index tanpa alasan yang jelas.

2. Cardinality memengaruhi efektivitas

Cardinality secara sederhana menggambarkan seberapa beragam nilai suatu kolom. Kolom dengan variasi nilai tinggi biasanya lebih selektif. Sebaliknya, kolom seperti status sering punya cardinality rendah.

Itu sebabnya index tunggal pada status sering mengecewakan. Tetapi saat digabung dengan tenant_id dan created_at, nilainya bisa tetap berguna karena query memang memerlukan kombinasi tersebut.

3. Index tidak selalu membantu

Composite index tidak akan otomatis berguna jika:

  • query sering memakai kondisi yang berbeda jauh dari urutan index
  • hasil filter tetap mencakup sebagian besar tabel
  • kolom di-WHERE dibungkus fungsi yang menghambat pemanfaatan index
  • query memakai operator yang kurang cocok untuk pola index tertentu
  • sorting dilakukan pada kolom yang tidak sejalan dengan filter utama

Contoh kesalahan yang sering terjadi:

WHERE DATE(created_at) = ?

Membungkus kolom dengan fungsi seperti ini dapat membuat index pada created_at menjadi kurang efektif. Lebih baik gunakan rentang waktu yang eksplisit jika memungkinkan.

4. Satu index tidak bisa melayani semua query dengan sempurna

Kalau satu tabel melayani banyak fitur dengan pola filter berbeda-beda, Anda perlu kompromi. Fokuskan index pada query yang paling kritis dari sisi frekuensi dan biaya, bukan mencoba mengoptimalkan semua skenario sekaligus.

Checklist verifikasi sebelum dan sesudah menambah composite index

Sebelum

  • Identifikasi endpoint atau halaman yang lambat
  • Ambil SQL aktual dari Eloquent
  • Catat pola query: kolom WHERE, ORDER BY, dan pagination
  • Jalankan EXPLAIN dan lihat index yang dipakai
  • Periksa apakah database masih membaca terlalu banyak baris atau melakukan sort tambahan
  • Cek index yang sudah ada agar tidak menambah index yang sebenarnya duplikatif

Sesudah

  • Jalankan migration penambahan composite index
  • Ulangi EXPLAIN pada query yang sama
  • Bandingkan apakah planner kini memakai composite index yang baru
  • Lihat apakah estimasi rows dan kebutuhan sort membaik
  • Uji beberapa variasi filter yang benar-benar dipakai user
  • Pastikan performa tulis tidak terdampak signifikan pada workload Anda
  • Evaluasi apakah ada index lama yang redundant

Kesalahan umum saat mengoptimalkan query Laravel

  • Langsung menambah banyak index tanpa membaca query nyata. Hasilnya bisa boros storage dan write cost meningkat.
  • Mengandalkan single-column index untuk query multi-kondisi. Ini salah satu penyebab paling umum daftar data tetap lambat.
  • Tidak memperhatikan urutan kolom dalam composite index. Kolom yang sama dengan urutan berbeda bisa menghasilkan manfaat yang sangat berbeda.
  • Fokus pada Eloquent saja, lupa database planner. ORM tidak menggantikan kebutuhan memahami cara database mengakses data.
  • Mengukur dengan data kecil. Query yang tampak baik di lokal belum tentu mewakili produksi.

Penutup

Untuk kasus halaman daftar dan filter data di Laravel, query yang menggabungkan WHERE, ORDER BY, dan pagination sering melambat bukan karena query-nya “salah”, tetapi karena index tidak mengikuti pola query. Di sinilah composite index yang tepat menjadi penting.

Mulailah dari query aktual yang lambat, baca EXPLAIN secara dasar, lalu susun index berdasarkan urutan filter dan sorting yang memang dipakai. Single-column index pada tiap kolom sering tidak cukup. Composite index seperti (tenant_id, status, created_at) bisa jauh lebih masuk akal untuk query yang memfilter tenant dan status lalu mengurutkan berdasarkan waktu.

Terakhir, jangan berhenti di penambahan index. Verifikasi hasilnya, pahami trade-off terhadap operasi tulis, dan pastikan index yang Anda buat memang selaras dengan pola akses data di aplikasi Laravel Anda.