N+1 query di Laravel sering dianggap masalah yang mudah dikenali: loop di controller memanggil relasi tanpa eager loading. Kenyataannya, kasus yang lebih berbahaya justru muncul saat query tambahan dipicu diam-diam oleh API Resource, transformer bertingkat, atau accessor model. Gejalanya biasanya baru terasa di produksi: latency endpoint naik, CPU database meningkat, dan jumlah query meledak saat ukuran pagination membesar.

Jika endpoint daftar awalnya terlihat normal saat memuat 10 item, masalah bisa tersembunyi sampai page size menjadi 50 atau 100. Pada titik itu, setiap item bisa memicu query tambahan untuk user, profile, comments, tags, atau accessor tertentu. Artikel ini membahas studi kasus nyata semacam itu: bagaimana mengidentifikasi sumber query, membuktikan akar masalahnya, lalu memperbaikinya tanpa membuat resource menjadi rapuh.

Studi kasus: endpoint list API melambat saat pagination membesar

Bayangkan ada endpoint untuk menampilkan daftar artikel internal:

GET /api/posts?page=1&per_page=50

Controller tampak cukup bersih:

public function index(Request $request)
{
    $perPage = min((int) $request->get('per_page', 15), 100);

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

    return PostResource::collection($posts);
}

Secara kasat mata belum terlihat kesalahan fatal. Namun gejala di lapangan mulai muncul:

  • Response time naik signifikan saat per_page diperbesar.
  • Grafik CPU database meningkat pada endpoint list, bukan hanya endpoint detail.
  • Jumlah query per request melonjak jauh lebih besar daripada jumlah item di halaman.
  • Slow query log menunjukkan banyak query kecil berulang, bukan satu query besar yang jelas bermasalah.

Masalah baru terlihat ketika kita membuka implementasi resource:

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => [
                'id' => $this->author->id,
                'name' => $this->author->name,
                'avatar' => $this->author->profile->avatar_url,
            ],
            'category' => [
                'id' => $this->category->id,
                'name' => $this->category->name,
            ],
            'latest_comment' => CommentResource::make($this->latestComment),
            'tags' => TagResource::collection($this->tags),
            'is_popular' => $this->is_popular,
        ];
    }
}

Di sini letak jebakannya: controller tidak melakukan loop relasi, tetapi resource melakukannya saat proses serialisasi. Jika relasi author, author.profile, category, latestComment, dan tags belum di-eager load, maka setiap akses properti relasi dapat memicu query tambahan per item.

Mengapa N+1 query di API Resource bertingkat sulit terlihat

1. Sumber query tidak ada di controller

Banyak review kode hanya memeriksa query builder di controller atau repository. Padahal serialisasi resource terjadi setelah query utama selesai. Akibatnya, logika yang tampak aman ternyata tetap menghasilkan ratusan query saat respons dibentuk.

2. Nested relation memperparah efeknya

Satu relasi yang tidak di-eager load sudah cukup menimbulkan N+1. Dalam resource bertingkat, masalah bisa berlipat:

  • $this->author memicu query per post.
  • $this->author->profile memicu query lagi per author.
  • $this->tags memicu query collection per post.

Jika ada 50 post dan beberapa relasi nested diakses, total query bisa naik tajam walau query utamanya hanya satu pagination query.

3. Accessor bisa menyembunyikan query tambahan

Accessor sering terlihat tidak berbahaya karena dipanggil seperti atribut biasa. Contoh:

public function getIsPopularAttribute(): bool
{
    return $this->views()->where('created_at', '>=', now()->subDays(7))->count() > 100;
}

Jika accessor seperti ini dipanggil di resource untuk setiap post, maka setiap item memicu query count() tambahan. Ini bukan relasi biasa yang mudah dikenali saat review. Karena bentuk aksesnya hanya $this->is_popular, bug performa sering lolos dari perhatian.

Langkah investigasi: temukan sumber query sebenarnya

Reproduksi lokal dengan data yang realistis

Jangan menguji hanya dengan 3 atau 5 data. N+1 sering terlihat jelas saat ukuran data cukup besar. Buat data lokal yang lebih mendekati produksi, misalnya puluhan atau ratusan post, masing-masing punya author, category, tag, dan komentar.

Tujuannya bukan mencari angka benchmark pasti, tetapi melihat pola: apakah jumlah query naik hampir linear terhadap jumlah item di halaman.

Pakai Laravel Debugbar atau Telescope

Untuk investigasi lokal, Laravel Debugbar nyaman dipakai jika endpoint juga diakses lewat browser atau selama pengembangan web internal. Jika aplikasi menggunakan API murni, Telescope biasanya lebih praktis untuk melihat daftar query per request beserta urutannya.

Hal yang perlu diperhatikan:

  • Jumlah total query pada satu request.
  • Query yang sama dieksekusi berulang dengan parameter berbeda.
  • Urutan query: sering kali satu query pagination diikuti puluhan query relasi yang pola SQL-nya mirip.

Pola klasik N+1 biasanya terlihat seperti:

  • Satu query mengambil daftar posts.
  • Lalu puluhan query select * from users where id = ?.
  • Lalu puluhan query select * from profiles where user_id = ?.
  • Lalu puluhan query pivot atau relation lain.

Aktifkan query log saat reproduksi terarah

Jika ingin pembuktian yang lebih eksplisit pada skenario tertentu, aktifkan query log secara terbatas pada environment lokal atau test. Misalnya, panggil endpoint yang sama dengan ukuran pagination berbeda, lalu bandingkan jumlah query.

DB::enableQueryLog();

$response = $this->getJson('/api/posts?per_page=50');

$queries = DB::getQueryLog();
logger()->info('Total queries', ['count' => count($queries)]);

Jangan jadikan pendekatan ini sebagai solusi permanen di produksi karena ada overhead. Gunakan untuk investigasi, bukan untuk observabilitas jangka panjang.

Periksa slow query log database

Slow query log berguna ketika gejalanya sudah muncul di staging atau produksi. Dalam kasus N+1, log ini kadang tidak menunjukkan satu query monster, tetapi banyak query kecil yang berulang. Itu justru petunjuk penting: bottleneck bisa berasal dari frekuensi query, bukan kompleksitas satu query tunggal.

Jika tidak ada query individual yang sangat lambat tetapi total waktu request tetap tinggi, curigai akumulasi banyak query kecil dari resource atau accessor.

Lacak titik serialisasi resource

Saat jumlah query terlihat berlebihan, fokuskan pencarian ke:

  • JsonResource utama.
  • Resource nested seperti UserResource, CommentResource, TagResource.
  • Accessor model yang dipanggil di toArray().
  • Append attribute yang otomatis ikut serialisasi.

Sering kali root cause bukan satu lokasi, melainkan kombinasi beberapa titik akses relasi yang tidak konsisten.

Root cause: relasi diakses tanpa eager loading yang konsisten

Masalah inti biasanya bukan sekadar “lupa pakai with()”, tetapi ketidakkonsistenan kontrak data antara layer query dan layer resource.

Contoh root cause yang umum:

  • Controller hanya memuat author, tetapi resource juga mengakses author.profile.
  • Controller untuk endpoint list dan detail menggunakan resource yang sama, tetapi kebutuhan eager loading keduanya berbeda.
  • Nested resource mengakses relasi lain yang tidak diketahui oleh resource induk.
  • Accessor atau appended attribute menjalankan query tambahan di luar relasi yang sudah di-eager load.

Akibatnya, walaupun sebagian relasi sudah dimuat, serialisasi tetap memicu lazy loading di titik lain.

Perbaikan konkret yang aman dan praktis

1. Eager load semua relasi yang benar-benar dipakai resource

Mulailah dari query utama dan buat daftar relasi yang memang dibutuhkan oleh output API.

public function index(Request $request)
{
    $perPage = min((int) $request->get('per_page', 15), 100);

    $posts = Post::query()
        ->with([
            'author.profile',
            'category',
            'latestComment.user',
            'tags',
        ])
        ->latest()
        ->paginate($perPage);

    return PostResource::collection($posts);
}

Ini adalah perbaikan paling langsung. Resource boleh tetap membaca relasi tersebut, karena query utama sudah menyiapkannya.

Mengapa ini bekerja? Karena Eloquent akan mengambil relasi dalam batch terpisah per jenis relasi, bukan satu query per item. Hasilnya, jumlah query menjadi jauh lebih stabil terhadap jumlah item dalam satu halaman.

2. Gunakan whenLoaded() di resource untuk mencegah lazy loading diam-diam

Jika resource dapat dipakai di beberapa endpoint dengan kebutuhan data berbeda, hindari akses relasi secara langsung. Gunakan whenLoaded() agar resource hanya menyerialisasi relasi yang memang sudah dimuat.

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => UserResource::make($this->whenLoaded('author')),
            'category' => CategoryResource::make($this->whenLoaded('category')),
            'latest_comment' => CommentResource::make($this->whenLoaded('latestComment')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
        ];
    }
}

Pada nested resource, lanjutkan pola yang sama:

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'profile' => ProfileResource::make($this->whenLoaded('profile')),
        ];
    }
}

Keuntungan pendekatan ini:

  • Resource tidak memicu query baru hanya karena properti diakses.
  • Kontrak antar-layer lebih jelas: query layer bertanggung jawab memuat data, resource hanya menyajikan data yang tersedia.
  • Bug performa lebih mudah dideteksi karena field bisa hilang saat relasi lupa dimuat, alih-alih diam-diam menembak query tambahan.

Trade-off-nya: Anda harus disiplin memastikan endpoint memuat relasi yang dibutuhkan. Jika tidak, sebagian field mungkin kosong atau tidak muncul.

3. Gunakan loadMissing() dengan hati-hati

loadMissing() berguna ketika Anda menerima model dari lapisan lain dan ingin memastikan relasi tertentu tersedia tanpa menimpa relasi yang sudah dimuat.

$posts->loadMissing([
    'author.profile',
    'category',
]);

Ini berguna di service atau action yang dipakai ulang. Namun ada batas penting: loadMissing() bukan alasan untuk membiarkan resource bebas melakukan lazy loading. Gunakan sebagai mekanisme defensif di layer yang masih dekat dengan pengambilan data, bukan di dalam toArray() resource.

Jika dipanggil terlalu terlambat atau tersebar di banyak tempat, alur data menjadi sulit dipahami.

4. Seleksi kolom agar eager loading tidak boros

Setelah menambah eager loading, masalah berikutnya adalah memori dan payload query yang berlebihan. Karena itu, pilih kolom yang benar-benar dibutuhkan bila memungkinkan.

$posts = Post::query()
    ->select(['id', 'author_id', 'category_id', 'title', 'created_at'])
    ->with([
        'author:id,name',
        'author.profile:id,user_id,avatar_url',
        'category:id,name',
        'tags:id,name',
    ])
    ->latest()
    ->paginate($perPage);

Perhatikan bahwa foreign key yang dibutuhkan untuk memetakan relasi tetap harus ikut dipilih. Jika terlalu agresif memangkas kolom penting, relasi bisa gagal dipetakan atau memicu perilaku yang membingungkan.

5. Refactor accessor yang memicu query

Accessor yang menjalankan query per item sebaiknya dihindari untuk endpoint list. Ada beberapa alternatif:

  • Pindahkan perhitungan ke query utama, misalnya dengan agregasi atau subquery jika memang relevan.
  • Gunakan withCount() atau mekanisme agregat relasi saat kebutuhan hanya jumlah data terkait.
  • Hitung di background dan simpan hasil denormalisasi jika metriknya berat tetapi sering dibaca.

Contoh mengganti accessor berbasis query dengan hitungan yang sudah disiapkan:

$posts = Post::query()
    ->withCount('views')
    ->paginate($perPage);

Lalu di resource:

'views_count' => $this->views_count,

Pendekatan tepatnya tergantung kebutuhan bisnis, tetapi prinsipnya sama: jangan biarkan atribut yang terlihat sederhana menyembunyikan query per item.

Pola review kode untuk mencegah bug serupa

Checklist review pada controller atau query layer

  • Apakah semua relasi yang dipakai oleh resource sudah terlihat jelas di with()?
  • Apakah ada nested relation seperti author.profile yang sering terlupa?
  • Apakah endpoint list memakai resource yang sama dengan endpoint detail padahal kebutuhan datanya berbeda?
  • Apakah pagination size maksimum masuk akal untuk struktur data saat ini?

Checklist review pada resource

  • Apakah resource mengakses relasi langsung dengan $this->relation tanpa whenLoaded()?
  • Apakah ada nested resource yang diam-diam mengakses relasi tambahan?
  • Apakah ada accessor, appended attribute, atau helper yang menjalankan query?
  • Apakah resource terlalu “pintar” sehingga berisi logika data fetching tersembunyi?

Guard praktis yang layak dipertimbangkan

Tim yang sering terkena masalah ini biasanya membuat aturan sederhana:

  • Di resource, akses relasi wajib melalui whenLoaded() untuk endpoint list.
  • Accessor yang menjalankan query harus diberi perhatian khusus saat review.
  • Resource tidak boleh memanggil load() atau loadMissing() secara sembarangan di dalam toArray().
  • Endpoint list dan detail boleh memakai resource berbeda jika kebutuhan datanya berbeda jauh.

Aturan seperti ini efektif karena mengubah N+1 dari masalah runtime yang samar menjadi masalah desain yang bisa ditangkap saat code review.

Trade-off eager loading yang perlu dipahami

Eager loading bukan solusi tanpa biaya. Menambah terlalu banyak relasi juga bisa berdampak negatif:

  • Memori aplikasi naik karena lebih banyak model dimuat.
  • Query relasi menjadi lebih besar dan memuat data yang tidak selalu dipakai.
  • Response bisa membesar bila resource terlalu kaya untuk endpoint list.

Karena itu, targetnya bukan “muat semua relasi”, melainkan muat relasi yang benar-benar dipakai oleh representasi API pada endpoint tersebut. Jika endpoint list hanya butuh nama author dan nama category, jangan ikut memuat relasi yang hanya berguna untuk endpoint detail.

Sering kali solusi paling sehat adalah memisahkan bentuk respons:

  • Resource ringkas untuk list.
  • Resource lengkap untuk detail.

Ini lebih eksplisit dan biasanya lebih mudah dioptimalkan.

Regression test performa sederhana

Performa sulit dijaga jika tidak ada pengaman. Anda tidak harus membuat benchmark rumit; cukup buat test sederhana yang memverifikasi jumlah query tidak melonjak pada endpoint penting.

Contoh pendekatan:

  1. Seed data dengan jumlah item yang cukup.
  2. Aktifkan query log pada test tersebut.
  3. Panggil endpoint list dengan pagination tertentu.
  4. Pastikan status respons benar dan jumlah query berada dalam batas yang masuk akal.
public function test_posts_index_does_not_trigger_excessive_queries(): void
{
    // Seed data secukupnya sesuai kebutuhan test.

    DB::flushQueryLog();
    DB::enableQueryLog();

    $response = $this->getJson('/api/posts?per_page=50');

    $response->assertOk();

    $queryCount = count(DB::getQueryLog());

    $this->assertLessThan(15, $queryCount);
}

Angka batas query harus disesuaikan dengan struktur endpoint Anda. Jangan menganggap angka di atas sebagai standar umum. Fokus utamanya adalah mencegah regresi besar, misalnya dari belasan query menjadi ratusan query setelah perubahan resource.

Jika jumlah query terlalu rapuh karena perubahan kecil yang sah, alternatifnya adalah membuat test pada level yang lebih spesifik untuk relasi wajib atau memeriksa pola lazy loading yang tidak diinginkan.

Checklist pencegahan N+1 query di API Resource bertingkat

  • Petakan relasi yang dipakai resource sebelum menulis query endpoint.
  • Gunakan with() secara eksplisit untuk relasi dan nested relation yang benar-benar dibutuhkan.
  • Di resource, prioritaskan whenLoaded() daripada akses relasi langsung.
  • Audit accessor dan appended attribute yang bisa memicu query tambahan.
  • Bedakan resource list dan detail jika kebutuhan datanya berbeda jauh.
  • Batasi per_page agar ledakan biaya tidak terlalu besar saat ada bug.
  • Gunakan Telescope atau Debugbar saat reproduksi lokal dan review pola query berulang.
  • Amati slow query log untuk menemukan akumulasi query kecil.
  • Pertimbangkan seleksi kolom agar eager loading tetap efisien.
  • Tambahkan regression test sederhana untuk endpoint kritis.

Penutup

Laravel: Debug N+1 Query Tersembunyi di API Resource Bertingkat pada dasarnya adalah soal disiplin memisahkan dua tanggung jawab: layer query memuat data yang dibutuhkan, layer resource hanya menyerialisasi data yang sudah tersedia. Bug ini sulit dideteksi karena controller bisa terlihat bersih, sementara query tambahan baru muncul saat resource dan accessor dijalankan.

Jika Anda melihat endpoint list tiba-tiba melambat saat pagination membesar, jangan hanya memeriksa SQL utama. Buka resource, nested resource, dan accessor. Di sanalah N+1 query Laravel sering bersembunyi. Perbaikannya biasanya bukan kompleks: with() yang tepat, whenLoaded() yang konsisten, seleksi kolom yang masuk akal, dan sedikit guard di code review sudah cukup untuk mencegah regresi performa yang mahal.