Saat daftar data Laravel mulai lambat setelah jumlah record bertambah, masalahnya sering bukan pada PHP atau Eloquent itu sendiri, melainkan pada cara query relasi dieksekusi. Pola yang paling sering muncul adalah N+1 query, filter atau sorting pada kolom relasi tanpa index, dan pagination parent-child yang memicu query mahal.

Solusinya biasanya gabungan dari dua hal: eager loading untuk mengurangi jumlah query, dan index database untuk mempercepat pencarian, join, filter, dan sorting. Yang penting, keduanya tidak saling menggantikan. Eager loading mengurangi jumlah round-trip query, tetapi database tetap bisa lambat bila kolom yang dipakai join atau filter tidak terindeks.

Gejala yang Perlu Dicurigai

Sebelum mengubah kode, identifikasi gejalanya lebih dulu. Tiga tanda umum:

  • Jumlah query sangat banyak untuk satu halaman, terutama saat menampilkan daftar parent beserta data child.
  • Total waktu query tinggi meskipun jumlah query tidak terlalu banyak, biasanya karena scan tabel besar, sort mahal, atau join tanpa index yang efektif.
  • Pagination makin lambat di halaman belakang, terutama ketika query melakukan join, order by, dan filter pada tabel relasi.

Membaca gejala di Laravel Debugbar atau Telescope

Jika memakai Laravel Debugbar atau Telescope, lihat:

  • Jumlah query pada satu request.
  • Query yang berulang dengan pola sama, hanya berbeda parameter id.
  • Query paling lambat, terutama yang mengandung join, exists, order by, atau where pada kolom relasi.

Contoh gejala N+1: Anda memuat 30 post, lalu untuk setiap post Laravel menjalankan query terpisah untuk author atau comments. Di Debugbar biasanya terlihat satu query utama lalu puluhan query serupa:

select * from posts order by created_at desc limit 30;
select * from users where users.id = ? limit 1;
select * from users where users.id = ? limit 1;
select * from users where users.id = ? limit 1;
...

Membaca dari log query

Untuk kasus yang tidak mudah ditangkap dari UI Debugbar, log query bisa membantu saat development:

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,
    ]);
});

Fokus pada dua hal: query yang muncul berulang dan query yang waktunya paling tinggi. Jangan hanya melihat satu query yang paling lambat; kadang bottleneck justru berasal dari query kecil yang dieksekusi ratusan kali.

Bottleneck 1: N+1 Query pada Relasi

N+1 terjadi ketika Anda mengambil data parent, lalu mengakses relasi child atau parent lain di dalam loop tanpa eager loading. Ini sangat umum pada halaman index, dashboard, API list, dan export data.

Contoh yang buruk

$posts = Post::latest()->paginate(30);

foreach ($posts as $post) {
    echo $post->title;
    echo $post->author->name;
    echo $post->category->name;
}

Kode di atas terlihat sederhana, tetapi jika author dan category belum di-load, setiap akses relasi bisa memicu query tambahan.

Perbaikan dengan eager loading

$posts = Post::with(['author:id,name', 'category:id,name'])
    ->latest()
    ->paginate(30);

foreach ($posts as $post) {
    echo $post->title;
    echo $post->author->name;
    echo $post->category->name;
}

Mengapa ini lebih cepat? Karena Eloquent akan mengambil relasi dalam batch, bukan satu per satu. Biasanya hasilnya adalah satu query untuk posts, satu query untuk semua author terkait, dan satu query untuk semua category terkait.

Catatan: Eager loading mengurangi jumlah query, tetapi tidak otomatis membuat query relasi cepat. Jika kolom seperti author_id atau category_id tidak punya index yang tepat, database tetap harus bekerja lebih berat.

Kapan eager loading membantu

  • Saat relasi akan dipakai untuk ditampilkan pada daftar.
  • Saat serialisasi API mengakses relasi pada banyak item.
  • Saat policy, accessor, atau resource tanpa sadar menyentuh relasi berulang.

Kapan eager loading saja tidak cukup

  • Saat Anda perlu filter berdasarkan kolom relasi.
  • Saat Anda perlu sort berdasarkan kolom relasi.
  • Saat relasi yang di-load sangat besar dan menyebabkan penggunaan memori naik.

Kesalahan umum adalah menganggap semua masalah relasi selesai dengan with(). Tidak. with() mengatasi pola akses, bukan desain index query di database.

Bottleneck 2: Filter dan Sort pada Kolom Relasi

Kasus ini lebih sulit karena jumlah query bisa sedikit, tetapi tetap lambat. Contohnya: menampilkan daftar post, filter berdasarkan nama author, lalu sort berdasarkan nama category atau tanggal dari tabel terkait.

Contoh query yang umum tetapi mahal

$posts = Post::query()
    ->whereHas('author', function ($q) use ($keyword) {
        $q->where('name', 'like', "%{$keyword}%");
    })
    ->with(['author', 'category'])
    ->latest()
    ->paginate(30);

Query seperti ini bisa tetap lambat walaupun sudah memakai eager loading, karena biaya utamanya ada di pencarian author berdasarkan name, bukan pada pemuatan relasi setelahnya.

Index yang biasanya wajib dipikirkan

  • Foreign key: seperti posts.author_id, posts.category_id, comments.post_id.
  • Kolom filter: seperti users.email, users.status, posts.published_at.
  • Kolom sort: seperti posts.created_at atau kombinasi kolom yang sering dipakai bersama.

Perlu dicatat, foreign key constraint tidak selalu berarti index sudah optimal untuk semua kebutuhan query. Pastikan struktur index sesuai query yang benar-benar dipakai aplikasi Anda.

Contoh migrasi menambahkan index

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('posts', function (Blueprint $table) {
            $table->index('author_id');
            $table->index('category_id');
            $table->index('created_at');
            $table->index(['status', 'published_at']);
        });

        Schema::table('users', function (Blueprint $table) {
            $table->index('status');
            $table->index('name');
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropIndex(['author_id']);
            $table->dropIndex(['category_id']);
            $table->dropIndex(['created_at']);
            $table->dropIndex(['status', 'published_at']);
        });

        Schema::table('users', function (Blueprint $table) {
            $table->dropIndex(['status']);
            $table->dropIndex(['name']);
        });
    }
};

Jangan menambahkan index secara membabi buta. Index mempercepat baca, tetapi menambah biaya saat insert, update, dan delete. Semakin banyak index, semakin besar overhead write dan storage.

Trade-off over-indexing

  • Write lebih lambat karena setiap perubahan data harus ikut memperbarui index.
  • Ukuran database membesar.
  • Planner query bisa memilih index yang kurang ideal jika banyak pilihan index mirip.

Prinsip praktisnya: buat index berdasarkan query nyata yang paling sering dan paling mahal, bukan berdasarkan semua kolom yang “mungkin” dipakai nanti.

Bottleneck 3: Pagination Parent-Child

Pagination sering terlihat aman, tetapi bisa menjadi mahal jika Anda menggabungkan daftar parent dengan relasi child, agregasi, filter relasi, dan sorting yang kompleks.

Contoh pola yang bermasalah

$posts = Post::with('comments')
    ->whereHas('author', function ($q) {
        $q->where('status', 'active');
    })
    ->orderByDesc('created_at')
    ->paginate(20);

Masalahnya bukan hanya pada query daftar, tetapi juga pada query count untuk pagination. Di data besar, paginate() bisa mahal karena perlu menghitung total row yang cocok.

Perbaikan yang lebih aman

  • Gunakan eager loading terbatas, ambil hanya kolom yang dibutuhkan.
  • Hindari memuat child collection besar untuk setiap parent bila hanya butuh jumlahnya.
  • Gunakan withCount() jika hanya perlu total child, bukan semua row child.
  • Pertimbangkan simplePaginate() bila total count tidak wajib ditampilkan.

Contoh perbaikan

$posts = Post::query()
    ->select(['id', 'author_id', 'category_id', 'title', 'created_at'])
    ->whereHas('author', function ($q) {
        $q->where('status', 'active');
    })
    ->with([
        'author:id,name',
        'category:id,name',
    ])
    ->withCount('comments')
    ->orderByDesc('created_at')
    ->simplePaginate(20);

Mengapa ini lebih baik?

  • Kolom yang diambil lebih sedikit.
  • Tidak memuat seluruh komentar jika hanya perlu jumlahnya.
  • simplePaginate() menghindari beban count total pada query kompleks.

Jika Anda tetap perlu paginate() biasa karena butuh total halaman, pastikan index untuk kolom filter dan sort sudah benar, lalu cek query count dengan EXPLAIN.

Verifikasi dengan EXPLAIN: Jangan Menebak

Setelah menambah eager loading atau index, jangan berhenti pada asumsi bahwa performa pasti membaik. Verifikasi dengan EXPLAIN terhadap query yang benar-benar dijalankan database.

Cara mengambil SQL dari Eloquent

Untuk inspeksi, Anda bisa melihat SQL mentah dari query builder, lalu menjalankannya di database client:

$query = Post::query()
    ->whereHas('author', function ($q) {
        $q->where('status', 'active');
    })
    ->orderByDesc('created_at');

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

Atau lebih praktis, salin query dari Debugbar/Telescope lalu jalankan:

EXPLAIN SELECT ... ;

Apa yang dilihat dari EXPLAIN

  • Apakah query memakai index yang diharapkan.
  • Apakah database melakukan full table scan pada tabel besar.
  • Berapa banyak row yang diperkirakan harus dibaca.
  • Apakah operasi sort atau filter terlihat mahal.

Anda tidak harus menjadi ahli planner database untuk mengambil manfaat dari EXPLAIN. Secara praktis, jika setelah penambahan index query masih membaca terlalu banyak row atau tetap tidak memakai index yang relevan, berarti desain index atau bentuk query masih perlu diperbaiki.

Tip: bandingkan EXPLAIN sebelum dan sesudah perubahan. Tujuannya bukan hanya “ada index”, tetapi “database benar-benar memakai index tersebut”.

Contoh Alur Perbaikan Nyata

Kasus

Halaman admin menampilkan daftar post dengan author, category, jumlah komentar, filter author aktif, dan urutan terbaru. Saat data masih kecil halaman terasa normal, tetapi setelah data bertambah request menjadi lambat.

Versi awal yang buruk

$posts = Post::latest()->paginate(25);

foreach ($posts as $post) {
    $authorName = $post->author->name;
    $categoryName = $post->category->name;
    $commentsCount = $post->comments->count();
}

Masalah:

  • N+1 pada author, category, dan comments.
  • Mengambil semua komentar hanya untuk menghitung jumlahnya.
  • Belum ada filter relasi yang dioptimalkan.

Versi yang diperbaiki

$posts = Post::query()
    ->select(['id', 'author_id', 'category_id', 'title', 'created_at'])
    ->whereHas('author', function ($q) {
        $q->where('status', 'active');
    })
    ->with([
        'author:id,name',
        'category:id,name',
    ])
    ->withCount('comments')
    ->orderByDesc('created_at')
    ->paginate(25);

Index yang perlu dicek:

  • posts.author_id
  • posts.category_id
  • posts.created_at
  • users.status
  • comments.post_id

Setelah itu, ambil query paling mahal dari Debugbar atau Telescope, lalu jalankan EXPLAIN untuk memastikan row yang dibaca turun dan index benar-benar dipakai.

Kesalahan yang Sering Terjadi

  • Menambah eager loading ke semua relasi tanpa melihat kebutuhan tampilan, sehingga memori dan payload justru membesar.
  • Menganggap foreign key otomatis cukup untuk semua pola query.
  • Memakai with() saat yang dibutuhkan sebenarnya withCount().
  • Menambahkan index pada terlalu banyak kolom tunggal padahal query lebih sering memakai kombinasi kolom tertentu.
  • Mengukur performa hanya dari waktu response total tanpa melihat query spesifik yang bermasalah.

Checklist Review Sebelum Deploy

  1. Apakah halaman list atau endpoint API memicu N+1 query pada relasi yang dipakai di loop?
  2. Apakah relasi yang dibutuhkan sudah di-load dengan eager loading yang selektif?
  3. Apakah Anda hanya mengambil kolom yang benar-benar diperlukan?
  4. Apakah kebutuhan jumlah child memakai withCount() alih-alih memuat seluruh collection?
  5. Apakah foreign key yang dipakai join dan relasi sudah memiliki index yang sesuai?
  6. Apakah kolom filter dan sort yang sering dipakai juga sudah diindeks?
  7. Untuk query pagination, apakah paginate() memang diperlukan, atau simplePaginate() sudah cukup?
  8. Apakah query paling mahal sudah diperiksa lewat Debugbar/Telescope atau log query?
  9. Apakah perbaikan sudah diverifikasi dengan EXPLAIN, bukan hanya asumsi?
  10. Apakah penambahan index baru sudah dipertimbangkan dampaknya pada write performance?

Penutup

Untuk mempercepat query relasi di Laravel, jangan memilih antara eager loading atau index seolah keduanya alternatif. Keduanya menyelesaikan masalah yang berbeda: eager loading mengurangi jumlah query, sedangkan index membantu database mengeksekusi query yang tersisa dengan lebih efisien.

Urutan kerja yang paling aman adalah: ukur gejalanya di Debugbar/Telescope atau log query, hilangkan N+1 dengan eager loading yang tepat, tambahkan index pada foreign key dan kolom filter/sort yang benar-benar dipakai, lalu verifikasi dengan EXPLAIN. Dengan pendekatan ini, Anda tidak sekadar menebak optimasi, tetapi memperbaiki bottleneck yang nyata.