Kenyamanan Eloquent punya sisi gelap: setiap rantai method kita diterjemahkan menjadi SQL yang tidak pernah kita lihat. Query yang boros jadi tidak terasa sampai halaman melambat di produksi, dan saat itu terjadi kita tidak tahu harus menyalahkan siapa. Kabar baiknya, Laravel membuka semua pintu inspeksinya — mulai dari membaca SQL satu query, menghitung jumlah query dalam satu proses, sampai memantau semua query yang lewat. Chapter penutup section ini melatih kebiasaan itu dan menghadirkan penyakit paling terkenal di dunia ORM: masalah N+1. Karena model Post kita sudah memakai Soft Deletes
, SQL pada contoh-contoh berikut memuat kondisi "deleted_at" is null — itu justri bukti bahwa global scope bekerja.
Membaca SQL dengan toSql() #
Setiap query builder
punya method toSql() yang menampilkan SQL mentah tanpa menjalankannya:
$query = Post::where('published', false)->orderBy('views');
echo $query->toSql(), "\n";
var_export($query->getBindings());Output yang Dihasilkan:
select * from "posts" where "published" = ? and "posts"."deleted_at" is null order by "views" asc
array (
0 => false,
)Nilai kolom tidak dijadikan satu dengan string SQL, melainkan dipisah sebagai placeholder ? dan binding false — lihat getBindings() untuk daftar nilainya sesuai urutan placeholder. Saat debugging di tengah kode, method dd() menggabungkan keduanya dan menghentikan eksekusi sehingga kita bisa memeriksa query tepat di titik yang dicurigai.
Masalah N+1 #
Kita siapkan data: Budi dan Siti menulis enam post secara bergantian. Lalu loop sederhana menampilkan nama penulis setiap post:
DB::enableQueryLog();
foreach (Post::orderBy('id')->get() as $post) {
echo $post->judul, ' ditulis ', $post->user->name, "\n";
}
$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach ($log as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
Optimasi Query 01 ditulis Siti
Optimasi Query 02 ditulis Budi
Optimasi Query 03 ditulis Siti
Optimasi Query 04 ditulis Budi
Optimasi Query 05 ditulis Siti
Optimasi Query 06 ditulis Budi
total query: 7
[select * from "posts" where "posts"."deleted_at" is null order by "id" asc]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]Outputnya benar, tetapi query log membeberkan biayanya: tujuh query. Ekspresi $post->user->name memicu lazy loading — setiap kali relasi user disentuh pertama kalinya, Eloquent menjalankan satu query SELECT baru. Enam post berarti enam query user di luar query posts; sejuta baris berarti sejuta satu query. Inilah masalah N+1: satu query untuk koleksi utama, lalu N query tambahan untuk relasi tiap baris.
Perbaikannya: with() #
Eager loading lewat with() memuat relasi di muka:
DB::flushQueryLog();
foreach (Post::with('user')->orderBy('id')->get() as $post) {
echo $post->judul, ' ditulis ', $post->user->name, "\n";
}
$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach ($log as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
Optimasi Query 01 ditulis Siti
Optimasi Query 02 ditulis Budi
Optimasi Query 03 ditulis Siti
Optimasi Query 04 ditulis Budi
Optimasi Query 05 ditulis Siti
Optimasi Query 06 ditulis Budi
total query: 2
[select * from "posts" where "posts"."deleted_at" is null order by "id" asc]
[select * from "users" where "users"."id" in (1, 2)]Jumlah query turun dari tujuh ke dua. Eloquent mengumpulkan semua user_id dari hasil query pertama, lalu mengambil semua user sekaligus dengan satu WHERE ... IN dan mencocokkannya di memori. Pembahasan lengkap eager loading — termasuk relasi bersarang dan kendala with() — kita tangani di Section Eloquent Relationships
.
Memantau Query dengan DB::listen() #
Query log yang kita pakai di atas harus diaktifkan lebih dulu dan hanya hidup selama proses PHP itu berjalan. Untuk pengawasan yang lebih luas, DB::listen() memasang callback yang dipanggil untuk setiap query berikutnya:
DB::listen(function ($query) {
echo '[SQL] ', $query->sql, "\n";
});
Post::count();
Post::where('id', 1)->first(['id', 'judul']);Output yang Dihasilkan:
[SQL] select count(*) as "aggregate" from "posts" where "posts"."deleted_at" is null
[SQL] select "id", "judul" from "posts" where "id" = ? and "posts"."deleted_at" is null limit 1Callback menerima objek QueryExecuted berisi SQL, bindings, dan waktunya — properti time memuat durasi dalam milidetik. Di aplikasi nyata, listener semacam ini biasanya dipasang sekali di service provider untuk menulis query lambat ke log, dan paket seperti Laravel Debugbar atau Telescope membangun seluruh fiturnya di atas mekanisme yang sama.
Memproses Data Besar dengan chunkById() #
get() memuat semua hasil ke memori sekaligus — aman untuk enam baris, bunuh diri untuk enam juta. Method chunkById() memproses tabel per potongan:
DB::enableQueryLog();
$jumlah = 0;
$potongan = 0;
Post::orderBy('id')->chunkById(4, function ($posts) use (&$jumlah, &$potongan) {
$potongan++;
$jumlah += $posts->count();
});
$log = DB::getQueryLog();
echo 'terproses ', $jumlah, ' post dalam ', $potongan, ' potongan (', count($log), " query)\n";
foreach ($log as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
terproses 6 post dalam 2 potongan (2 query)
[select * from "posts" where "id" is not null and "posts"."deleted_at" is null order by "id" asc limit 4]
[select * from "posts" where "id" > ? and "posts"."deleted_at" is null order by "id" asc limit 4]Enam post terbagi dua potongan empat baris, dan closure menerima maksimal empat model dalam satu waktu — memori tetap kecil berapa pun ukuran tabelnya. Perhatikan query keduanya memakai where "id" > ?, bukan offset: potongan berikutnya dimulai setelah id terakhir yang diproses. Inilah alasan memilih chunkById() daripada chunk() yang berbasis offset — offset besar membuat database membaca ulang semua baris sebelumnya, dan offset bergeser bila data berubah di tengah pemrosesan.
Memilih Kolom dan Mengecek Keberadaan #
Dua penghematan terakhir menyusutkan kerja itu sendiri. Pertama, seleksi hanya kolom yang dipakai — klausa SELECT pada SQL di atas berubah total:
echo Post::select('id', 'judul')->orderBy('id')->toSql(), "\n";
$post = Post::select('id', 'judul')->orderBy('id')->first();
echo 'atribut terpilih: id=', $post->id, ', judul=', $post->judul, "\n";
echo 'isi (tidak diseleksi): ', var_export($post->isi, true), "\n";Output yang Dihasilkan:
select "id", "judul" from "posts" where "posts"."deleted_at" is null order by "id" asc
atribut terpilih: id=1, judul=Optimasi Query 01
isi (tidak diseleksi): NULLKedua, untuk sekadar menjawab “ada tidak”, gunakan exists() alih-alih count() atau get():
DB::flushQueryLog();
var_export(Post::where('published', false)->exists());
echo "\n";
foreach (DB::getQueryLog() as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
true
[select exists(select * from "posts" where "published" = ? and "posts"."deleted_at" is null) as "exists"]Database membungkus query dalam select exists(...) dan berhenti begitu baris pertama ditemukan — tidak perlu menghitung sampai habis seperti count(), apalagi memuat model seperti first().
Dengan toSql(), query log, DB::listen(), eager loading, chunking, dan seleksi kolom, kita punya satu set alat ukur dan perbaikan yang bisa dipakai di titik mana pun. Perjalanan Eloquent belum selesai: model yang selama ini berdiri sendiri kini akan saling terhubung — satu post milik satu user, satu user punya banyak komentar — dan Section Eloquent Relationships
membahasnya secara penuh.