Banyak developer Laravel menganggap masalah performa selesai begitu query N+1 diatasi dengan eager loading lewat with(). Pada praktiknya, endpoint tetap bisa lambat walaupun jumlah query sudah turun drastis. Penyebabnya sering bukan lagi banyaknya query, tetapi terlalu banyak data yang diambil, relasi bertingkat yang tidak benar-benar dipakai, agregasi yang dikerjakan dengan cara mahal, serta proses serialisasi model yang diam-diam memicu kerja tambahan.
Artikel ini membahas kasus saat query sudah memakai with() tetapi response masih lambat. Fokus utamanya adalah overfetching kolom, nested eager loading berlebihan, penggunaan count agregat yang lebih efisien, efek hidden/appends, serta biaya serialisasi resource. Di akhir, kita juga bahas cara mengecek query sebenarnya agar optimasi tidak berdasarkan tebakan.
Masalah Utama: with() Mengurangi Query, Bukan Otomatis Mengurangi Beban Data
with() menyelesaikan satu masalah spesifik: menghindari query relasi yang dijalankan satu per satu. Namun, with() tidak otomatis memastikan bahwa data yang diambil adalah data yang benar-benar dibutuhkan.
Contoh sederhana, sebuah endpoint daftar artikel mungkin hanya menampilkan:
- judul artikel,
- nama penulis,
- jumlah komentar,
- tanggal publish.
Tetapi implementasinya mengambil:
- semua kolom artikel, termasuk body panjang, metadata SEO, dan JSON konfigurasi,
- semua kolom user penulis, termasuk email, bio, avatar variants, preference JSON,
- semua komentar lengkap dengan author dan likes, padahal yang dibutuhkan hanya totalnya.
Secara jumlah query mungkin sudah baik, tetapi ukuran data yang ditransfer dari database, di-hydrate menjadi model Eloquent, dan diubah menjadi JSON bisa menjadi sangat besar. Di titik ini, bottleneck berpindah dari database round-trip menjadi I/O, memory usage, hydration cost, dan serialization cost.
Catatan penting: endpoint lambat bukan selalu karena query terlalu banyak. Bisa jadi query-nya sedikit, tetapi hasilnya terlalu gemuk.
Kurangi Kolom yang Diambil, Jangan Selalu Select *
Kenapa ini penting
Secara default, Eloquent cenderung mengambil semua kolom model utama dan semua kolom relasi yang di-eager load. Padahal, banyak endpoint hanya perlu sebagian kecil kolom. Semakin besar kolom yang diambil, semakin besar pula:
- data yang dibaca dari database,
- data yang dikirim melalui koneksi DB,
- biaya pembuatan object model,
- biaya encode JSON di response API.
Ini sangat terasa jika tabel memiliki kolom TEXT, LONGTEXT, JSON, atau banyak kolom turunan yang tidak relevan untuk daftar data.
Contoh yang terlalu boros
$posts = Post::with(['author', 'category'])->latest()->paginate(20);Kode di atas akan mengambil semua kolom dari posts, users sebagai author, dan categories. Untuk endpoint list, ini sering berlebihan.
Contoh lebih efisien dengan select terbatas
$posts = Post::query()
->select(['id', 'author_id', 'category_id', 'title', 'slug', 'published_at'])
->with([
'author:id,name',
'category:id,name,slug',
])
->latest('published_at')
->paginate(20);Pendekatan ini lebih hemat karena hanya mengambil kolom yang benar-benar dipakai endpoint.
Hal yang sering terlupakan: saat membatasi kolom relasi, jangan lupa sertakan primary key dan foreign key yang diperlukan agar mapping relasi tetap berjalan benar. Misalnya pada relasi belongsTo, model utama harus tetap punya author_id, dan relasi author harus mengambil id.
Kesalahan umum saat membatasi kolom
- Lupa mengambil kolom kunci seperti
id,post_id, atauauthor_id. - Menggunakan accessor/resource yang ternyata membutuhkan kolom lain yang tidak ikut di-select.
- Mengira semua endpoint bisa memakai set kolom yang sama. Padahal kebutuhan detail page dan list page berbeda.
Solusi praktisnya adalah rancang query sesuai kebutuhan endpoint, bukan berdasarkan “model ini biasanya punya kolom apa saja”.
Hindari Nested Eager Loading yang Tidak Perlu
Gejala umum
Sering ditemukan pola seperti ini:
$posts = Post::with([
'author.profile',
'comments.user',
'comments.likes',
'category.parent',
])->paginate(20);Secara teknis ini valid. Namun, bila endpoint hanya menampilkan daftar post dengan nama author dan jumlah komentar, nested eager loading seperti di atas adalah bentuk overfetching relasi.
Setiap relasi tambahan berarti:
- query tambahan,
- lebih banyak record diambil,
- lebih banyak model di-hydrate,
- lebih banyak object yang akhirnya diserialisasi.
Pilih relasi sesuai kebutuhan response
Untuk endpoint list, sering kali cukup seperti ini:
$posts = Post::query()
->select(['id', 'author_id', 'title', 'published_at'])
->with(['author:id,name'])
->withCount('comments')
->paginate(20);Di sini kita tidak mengambil seluruh komentar, user komentar, atau likes komentar. Kita hanya mengambil jumlahnya lewat agregasi yang jauh lebih murah untuk kebutuhan response.
Kapan memakai loadMissing()
loadMissing() berguna ketika model sudah diperoleh, dan Anda hanya ingin memuat relasi jika memang belum tersedia. Ini membantu menghindari eager loading terlalu dini atau terlalu banyak pada query utama.
$post = Post::query()
->select(['id', 'author_id', 'title', 'body'])
->findOrFail($id);
if ($needAuthor) {
$post->loadMissing('author:id,name');
}
if ($needCategory) {
$post->loadMissing('category:id,name,slug');
}Pendekatan ini cocok bila kebutuhan relasi bergantung pada konteks, misalnya parameter request, role user, atau mode response tertentu.
Trade-off: jika semua endpoint selalu membutuhkan relasi yang sama, memanggil with() di query awal biasanya lebih sederhana. loadMissing() lebih berguna untuk kondisi dinamis atau saat Anda ingin menghindari pemuatan relasi bertingkat pada semua jalur eksekusi.
Gunakan withCount untuk Agregat, Bukan Memuat Semua Data
Anti-pattern yang sering terjadi
Banyak endpoint hanya membutuhkan total relasi, tetapi implementasinya memuat seluruh relasi lalu menghitung di PHP:
$posts = Post::with('comments')->get();
return $posts->map(function ($post) {
return [
'id' => $post->id,
'title' => $post->title,
'comments_count' => $post->comments->count(),
];
});Ini mahal karena seluruh komentar tetap diambil dari database dan dibuat menjadi collection model, padahal yang dibutuhkan hanya jumlahnya.
Pendekatan yang lebih tepat
$posts = Post::query()
->select(['id', 'title', 'author_id'])
->with(['author:id,name'])
->withCount('comments')
->paginate(20);withCount() meminta database menghitung jumlah relasi dan mengembalikan kolom turunan seperti comments_count. Ini biasanya jauh lebih efisien untuk kebutuhan list API.
Varian agregat lain
Selain withCount(), Eloquent juga menyediakan pendekatan agregat lain seperti withSum(), withAvg(), atau withExists() pada kasus tertentu. Prinsip umumnya sama: biarkan database mengerjakan agregasi, jangan memindahkan pekerjaan itu ke PHP bila tidak perlu.
Namun, tetap perhatikan kompleksitas query. Jika relasi sangat besar dan kondisi agregasinya rumit, cek rencana eksekusi query dan indeks yang tersedia.
Waspadai appends, Accessor Mahal, dan Hidden yang Menyesatkan
Hidden tidak berarti query lebih ringan
Banyak orang mengira jika sebuah kolom disembunyikan lewat $hidden, maka kolom itu tidak akan diambil dari database. Ini keliru. $hidden hanya mempengaruhi output serialisasi, bukan query SQL.
Artinya, jika Anda masih melakukan select *, semua kolom tetap dibaca. Hidden hanya mencegah kolom tertentu tampil di JSON.
Appends bisa memicu kerja tambahan
Properti di $appends sering terlihat nyaman karena otomatis muncul di output API. Namun, accessor di balik append bisa mahal, misalnya:
- memproses string besar,
- mengakses relasi yang belum di-load,
- menghitung sesuatu untuk setiap item collection,
- membuat URL turunan atau metadata kompleks.
Contoh pola berbahaya:
protected $appends = ['is_liked'];
public function getIsLikedAttribute()
{
return $this->likes()
->where('user_id', auth()->id())
->exists();
}Jika ini dipanggil untuk 20 post dalam list, Anda bisa tanpa sadar menambah query per item. Walaupun relasi utama sudah di-eager load, append seperti ini bisa menciptakan bottleneck baru.
Praktik yang lebih aman
- Jangan jadikan semua accessor sebagai
$appendsglobal. - Pindahkan field turunan yang mahal ke API Resource atau transformer agar hanya dihitung saat benar-benar dibutuhkan.
- Jika accessor perlu data relasi, pastikan relasinya memang sudah di-load, atau hitung nilainya lewat query agregat terarah.
Serialisasi Resource Juga Bisa Menjadi Bottleneck
Masalahnya bukan hanya query
Setelah data berhasil diambil, Laravel masih harus mengubah model dan relasinya menjadi array/JSON. Jika Resource terlalu agresif, biaya serialisasi bisa signifikan, terutama pada response list dengan banyak item dan relasi bertingkat.
Contoh resource yang terlalu rakus:
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'author' => new UserResource($this->author),
'comments' => CommentResource::collection($this->comments),
'category' => new CategoryResource($this->category),
'meta' => [
'reading_time' => str_word_count($this->body),
],
];
}Untuk endpoint detail mungkin masuk akal. Untuk endpoint list, ini berlebihan.
Gunakan serialisasi kondisional
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'published_at' => $this->published_at,
'author' => new UserResource($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),
];
}Dengan pola ini, resource hanya memproses relasi jika memang sudah dimuat. Ini mencegah akses relasi yang tidak perlu dan membuat kontrak antara query dan resource menjadi lebih jelas.
Prinsip penting: query dan resource harus selaras. Jika resource mengharapkan relasi tertentu, pastikan query memuatnya. Jika endpoint tidak butuh relasi tersebut, jangan paksa resource untuk selalu membangunnya.
Cara Mengecek Query Sebenarnya, Jangan Mengandalkan Perasaan
Lihat SQL yang benar-benar dijalankan
Optimasi performa sebaiknya dimulai dari observasi. Jangan berasumsi bahwa bottleneck ada di tempat tertentu tanpa data.
Beberapa cara praktis untuk mengecek query:
use Illuminate\Support\Facades\DB;
DB::listen(function ($query) {
logger()->debug('SQL', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time_ms' => $query->time,
]);
});Dengan DB::listen(), Anda bisa melihat SQL, binding, dan waktu eksekusi. Ini membantu menjawab pertanyaan seperti:
- Apakah relasi tertentu ternyata tetap memicu query tambahan?
- Apakah query utama mengambil terlalu banyak kolom?
- Apakah count/agregat dilakukan dengan cara yang efisien?
Gunakan alat bantu saat development
Selain logging manual, Anda juga bisa memakai tool observabilitas di lingkungan development seperti Laravel Debugbar atau Telescope bila sesuai dengan kebijakan proyek. Tujuannya bukan sekadar melihat jumlah query, tetapi juga isi query, durasi, dan pola akses relasi.
Periksa ukuran payload response
Jangan hanya fokus pada waktu query database. Cek juga:
- ukuran JSON response,
- waktu serialisasi resource,
- memory usage saat memproses collection besar.
Ada kasus di mana query hanya memakan sedikit waktu, tetapi response tetap lambat karena payload terlalu besar atau transformasi data terlalu berat.
Pola Optimasi yang Praktis untuk Endpoint List
Jika Anda menangani endpoint list yang sudah memakai with() tetapi masih lambat, urutan evaluasi berikut biasanya efektif:
- Batasi kolom model utama dengan
select(). - Batasi kolom relasi di dalam
with(). - Hapus nested eager loading yang tidak muncul di response.
- Ganti pemuatan relasi penuh dengan
withCount()bila hanya butuh total. - Tinjau accessor dan
$appendsyang berjalan per item. - Pastikan resource tidak memaksa serialisasi relasi yang tidak dibutuhkan.
- Log query nyata dan ukur ukuran response.
Contoh implementasi yang lebih seimbang:
$posts = Post::query()
->select(['id', 'author_id', 'category_id', 'title', 'slug', 'published_at'])
->with([
'author:id,name',
'category:id,name,slug',
])
->withCount('comments')
->latest('published_at')
->paginate(20);
return PostListResource::collection($posts);Dan resource-nya:
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'published_at' => $this->published_at,
'comments_count' => $this->whenCounted('comments'),
'author' => new UserResource($this->whenLoaded('author')),
'category' => new CategoryResource($this->whenLoaded('category')),
];
}Pola ini menjaga agar query tetap hemat, agregat dihitung di database, dan resource tidak melakukan kerja tambahan di luar kebutuhan endpoint.
Penutup
Eager loading bukan peluru ajaib. Ia sangat efektif untuk mengatasi N+1, tetapi tidak otomatis membuat endpoint cepat. Saat with() sudah dipakai namun performa masih buruk, biasanya masalah bergeser ke overfetching kolom, nested relasi yang terlalu dalam, agregasi yang salah tempat, append/accessor mahal, dan serialisasi resource yang terlalu banyak pekerjaan.
Fokus optimasi yang lebih matang adalah mengambil data secukupnya, pada waktu yang tepat, dengan bentuk yang sesuai kebutuhan response. Mulailah dari pengamatan query nyata, lalu rapikan query, relasi, dan resource agar saling konsisten. Dengan begitu, Anda tidak hanya mengurangi jumlah query, tetapi juga mengurangi total beban kerja endpoint secara menyeluruh.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!