Route model binding membuat controller Laravel lebih ringkas karena parameter route otomatis diubah menjadi instance model. Namun kemudahan ini sering menimbulkan bug yang sulit dilacak: route terlihat valid tetapi hasilnya 404, atau lebih berbahaya, model yang dikembalikan bukan data yang dimaksud. Penyebab yang paling umum adalah query binding ikut terkena global scope, data sedang soft deleted, atau ada pembatasan multi-tenant.

Masalah utamanya bukan pada fitur binding itu sendiri, melainkan pada fakta bahwa Laravel melakukan pencarian model lewat query Eloquent seperti biasa. Artinya, semua filter yang aktif pada model juga dapat memengaruhi proses pencarian data saat binding terjadi. Jika hal ini tidak dipahami, gejalanya terlihat seperti route “acak 404”, padahal sebenarnya query binding sedang difilter oleh scope tertentu.

Artikel ini membahas bagaimana masalah tersebut terjadi, cara mengenalinya, teknik debugging yang relevan, serta kapan sebaiknya memakai resolveRouteBinding(), withTrashed(), dan withoutGlobalScopes() secara aman, terutama pada aplikasi multi-tenant.

Bagaimana Route Model Binding Bekerja

Misalkan ada route seperti ini:

use App\Models\Post;
use Illuminate\Support\Facades\Route;

Route::get('/posts/{post}', function (Post $post) {
    return $post;
});

Laravel akan mengambil nilai {post} dari URL, lalu menjalankan query ke model Post. Secara default, pencarian dilakukan berdasarkan primary key. Jika model tidak ditemukan, Laravel otomatis melempar 404 Not Found.

Yang sering terlewat adalah: query ini bukan query istimewa. Ia tetap tunduk pada perilaku Eloquent, termasuk:

  • Global scope yang dipasang pada model,
  • Filter SoftDeletes,
  • Scope tenant berdasarkan tenant_id,
  • Custom binding key seperti slug,
  • Override pada resolveRouteBinding().

Karena itu, jika ada data secara fisik di database tetapi tidak lolos filter query, hasil akhirnya tetap 404.

Penyebab Umum Binding Salah atau 404

1. Global Scope Memfilter Data

Contoh klasik adalah model hanya menampilkan data berstatus published:

protected static function booted(): void
{
    static::addGlobalScope('published', function ($query) {
        $query->where('status', 'published');
    });
}

Jika route mengakses post draft:

Route::get('/posts/{post}', function (Post $post) {
    return $post;
});

Maka URL seperti /posts/123 bisa berakhir 404 walaupun record dengan ID 123 ada di database, karena route model binding ikut terkena scope published.

2. Data Sedang Soft Deleted

Pada model yang memakai SoftDeletes, query default tidak akan mengambil baris yang memiliki nilai deleted_at.

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

Jika post sudah di-soft delete, route model binding standar tidak akan menemukannya dan hasilnya 404.

3. Scope Multi-Tenant Membatasi Akses Tenant Aktif

Pada aplikasi multi-tenant, model sering dipasangi global scope seperti:

protected static function booted(): void
{
    static::addGlobalScope('tenant', function ($query) {
        $query->where('tenant_id', tenant()->id);
    });
}

Scope ini benar untuk keamanan data. Namun saat binding dilakukan, Laravel juga hanya mencari data milik tenant aktif. Jika parameter route mengarah ke record tenant lain, data tidak akan ditemukan.

Dalam banyak kasus, hasil ini memang diharapkan. Tetapi saat debugging, developer sering mengira datanya hilang, padahal sebenarnya diblok oleh scope tenant.

4. Binding Berdasarkan Slug atau Kolom Lain

Jika model menggunakan custom route key:

public function getRouteKeyName(): string
{
    return 'slug';
}

Maka route /posts/laravel-tips akan mencari berdasarkan slug, bukan id. Jika ditambah global scope atau soft delete, pencarian menjadi lebih ketat lagi. Ini sering membuat developer salah asumsi bahwa binding sedang mencari berdasarkan ID.

Contoh Gejala yang Sering Terjadi

  • URL valid untuk admin, tetapi 404 untuk user biasa.
  • Data ada di database, tetapi route tetap gagal.
  • Setelah menambahkan global scope baru, beberapa endpoint lama mendadak 404.
  • Record yang baru di-soft delete tidak bisa lagi diakses lewat route binding.
  • Pada sistem multi-tenant, admin pusat bisa melihat data, tetapi tenant user tidak bisa.

Semua gejala tersebut masuk akal jika dipahami bahwa binding memakai query Eloquent yang sama-sama terkena filter.

Cara Debug Route Model Binding yang Bermasalah

1. Pastikan Nilai Parameter Route Benar

Langkah awal: pastikan nilai route memang sesuai dengan key yang digunakan model.

Route::get('/debug/posts/{post}', function ($post) {
    return ['route_param' => $post];
});

Jika model memakai slug, jangan menguji URL dengan asumsi itu adalah ID.

2. Uji Query Manual ke Model

Coba replikasi query secara manual di Tinker atau sementara di controller:

$post = Post::query()->find($id);
$postWithoutScopes = Post::withoutGlobalScopes()->find($id);
$postWithTrashed = Post::withTrashed()->find($id);

Bandingkan hasilnya:

  • Jika find() null tetapi withoutGlobalScopes() berhasil, masalahnya kemungkinan global scope.
  • Jika find() null tetapi withTrashed() berhasil, masalahnya kemungkinan soft delete.
  • Jika hanya gagal pada tenant tertentu, cek scope tenant dan konteks tenant aktif.

3. Periksa SQL Query yang Dihasilkan

Untuk melihat query aktual yang dijalankan:

$query = Post::query()->whereKey($id);

dd(
    $query->toSql(),
    $query->getBindings()
);

Atau gunakan DB::listen() saat debugging lokal:

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger()->debug('SQL', [
        'sql' => $query->sql,
        'bindings' => $query->bindings,
        'time' => $query->time,
    ]);
});

Dari sini biasanya terlihat adanya kondisi tambahan seperti status = published, deleted_at is null, atau tenant_id = ?.

4. Cek Apakah Model Mengoverride resolveRouteBinding()

Beberapa proyek sudah memiliki logika binding khusus di model:

public function resolveRouteBinding($value, $field = null)
{
    return $this->where($field ?? $this->getRouteKeyName(), $value)
        ->where('status', 'published')
        ->firstOrFail();
}

Jika method ini ada, maka perilaku binding bisa berbeda dari yang Anda kira. Selalu periksa implementasi model terkait.

Kapan Memakai withTrashed()

Gunakan withTrashed() jika secara bisnis route memang boleh mengakses data yang sudah di-soft delete. Contoh yang umum:

  • Halaman admin untuk audit,
  • Halaman restore data,
  • Riwayat transaksi yang tetap perlu dilihat walau entitas utamanya sudah dihapus.

Contoh lewat override binding:

public function resolveRouteBinding($value, $field = null)
{
    return $this->withTrashed()
        ->where($field ?? $this->getRouteKeyName(), $value)
        ->firstOrFail();
}

Catatan: jangan otomatis mengaktifkan withTrashed() untuk semua route jika tidak benar-benar dibutuhkan. Data yang sudah dihapus sering kali memang seharusnya tidak dapat diakses dari endpoint publik.

Kapan Memakai withoutGlobalScopes()

withoutGlobalScopes() adalah alat yang kuat, tetapi juga berisiko. Method ini menghapus semua global scope dari query, termasuk scope keamanan seperti tenant isolation atau data visibility.

Contoh:

public function resolveRouteBinding($value, $field = null)
{
    return $this->withoutGlobalScopes()
        ->where($field ?? $this->getRouteKeyName(), $value)
        ->firstOrFail();
}

Secara teknis ini bisa “memperbaiki” masalah 404. Tetapi pada aplikasi multi-tenant, ini juga bisa membuka data tenant lain jika tidak disertai filter pengganti yang benar.

Lebih Aman: Lepas Scope Tertentu Saja

Jika masalahnya hanya satu scope, lebih baik lepaskan scope itu saja:

return $this->withoutGlobalScope('published')
    ->where($field ?? $this->getRouteKeyName(), $value)
    ->firstOrFail();

Atau jika memakai class scope:

return $this->withoutGlobalScope(PublishedScope::class)
    ->where($field ?? $this->getRouteKeyName(), $value)
    ->firstOrFail();

Dengan begitu, scope tenant atau pembatas keamanan lain tetap aktif.

Prinsip penting: jangan menghapus scope keamanan hanya demi membuat binding “berfungsi”. Pastikan ada alasan bisnis yang jelas dan proteksi pengganti yang setara.

Waktu yang Tepat Menggunakan resolveRouteBinding()

resolveRouteBinding() cocok digunakan ketika aturan binding model memang berbeda dari query default. Misalnya:

  • Perlu mengikutkan data soft deleted pada route tertentu,
  • Perlu mencari data berdasarkan relasi atau kombinasi kondisi tertentu,
  • Perlu menghapus satu global scope non-keamanan,
  • Perlu menambahkan validasi akses yang spesifik saat binding.

Contoh implementasi yang lebih aman pada sistem multi-tenant:

public function resolveRouteBinding($value, $field = null)
{
    return $this->newQuery()
        ->withTrashed()
        ->where('tenant_id', tenant()->id)
        ->where($field ?? $this->getRouteKeyName(), $value)
        ->firstOrFail();
}

Pada contoh di atas, kita mengizinkan data soft deleted ikut dicari, tetapi tetap menjaga pembatasan tenant secara eksplisit.

Gunakan newQuery() bila Ingin Jelas dan Terkontrol

Dalam beberapa kasus, menggunakan newQuery() atau newQueryWithoutScopes() dapat membuat niat kode lebih jelas daripada menumpuk banyak modifier. Namun, jika memilih newQueryWithoutScopes(), Anda wajib membangun ulang filter keamanan yang diperlukan.

public function resolveRouteBinding($value, $field = null)
{
    return $this->newQueryWithoutScopes()
        ->where('tenant_id', tenant()->id)
        ->where($field ?? $this->getRouteKeyName(), $value)
        ->firstOrFail();
}

Pola ini berguna jika Anda ingin melepas semua scope presentasi, tetapi tetap mempertahankan isolasi tenant secara manual.

Praktik Aman pada Aplikasi Multi-Tenant

Pada aplikasi multi-tenant, route model binding bukan hanya soal kenyamanan developer, tetapi juga bagian dari batas keamanan data. Karena itu, perhatikan beberapa prinsip berikut:

  1. Jangan menghapus scope tenant secara global hanya untuk mengatasi 404.
  2. Bedakan route internal admin dan route tenant user; kebutuhan binding keduanya sering berbeda.
  3. Pastikan authorization tetap berjalan meskipun binding berhasil. Binding menemukan model bukan berarti user boleh mengaksesnya.
  4. Jangan jadikan withTrashed() sebagai default untuk endpoint publik.
  5. Uji skenario lintas tenant secara eksplisit di test otomatis.

Idealnya, akses data dijaga berlapis: binding benar, scope benar, dan policy/authorization juga benar.

Contoh Kombinasi dengan Authorization

Walaupun route binding sudah membatasi query, tetap lakukan otorisasi di controller atau policy:

Route::get('/posts/{post}', function (Post $post) {
    $this->authorize('view', $post);

    return $post;
});

Atau di controller:

public function show(Post $post)
{
    $this->authorize('view', $post);

    return response()->json($post);
}

Ini penting karena ada kasus di mana binding sengaja dilonggarkan untuk kebutuhan admin atau audit, tetapi akses tetap harus dibatasi berdasarkan role atau policy.

Checklist Saat Route Model Binding Tiba-Tiba 404

  • Apakah parameter route sesuai dengan getRouteKeyName()?
  • Apakah record ada di database?
  • Apakah record sedang soft deleted?
  • Apakah ada global scope yang memfilter data?
  • Apakah tenant aktif sudah benar?
  • Apakah model punya resolveRouteBinding() custom?
  • Apakah Anda perlu melepas satu scope saja, bukan semua scope?
  • Apakah policy atau authorization menolak akses setelah binding?

Kapan Tidak Perlu Mengubah Apa Pun

Tidak semua 404 dari route model binding adalah bug. Kadang justru itu perilaku yang benar. Misalnya:

  • User tenant A tidak boleh mengakses data tenant B,
  • Data draft tidak boleh terlihat di endpoint publik,
  • Data yang sudah dihapus memang tidak boleh diakses lagi.

Dalam kasus seperti ini, solusi terbaik bukan melonggarkan binding, melainkan memahami bahwa filter tersebut adalah bagian dari aturan bisnis dan keamanan aplikasi.

Kesimpulan

Route model binding Laravel bekerja lewat query Eloquent biasa. Karena itu, hasil binding dapat dipengaruhi oleh global scope, soft delete, custom route key, dan pembatasan multi-tenant. Inilah sebabnya route yang tampak valid bisa berujung 404, atau terlihat seperti mengambil data yang salah.

Untuk menanganinya, mulai dari debugging dasar: cek parameter route, pastikan key pencarian benar, uji query manual, dan lihat SQL yang dihasilkan. Jika perlu menyesuaikan perilaku binding, gunakan resolveRouteBinding() dengan tujuan yang jelas. Pakai withTrashed() hanya saat memang diperlukan, dan gunakan withoutGlobalScopes() dengan sangat hati-hati—terutama jika ada scope tenant atau scope keamanan lain.

Singkatnya: jangan hanya membuat binding “ketemu datanya”. Pastikan solusi Anda tetap menjaga aturan bisnis, isolasi tenant, dan keamanan akses data.