Di chapter Route Parameters , parameter seperti {id} sampai ke handler sebagai string, lalu handler sendiri yang memanggil Post::findOrFail($id) dan menangani kemungkinan record tidak ada. Route model binding memangkas pekerjaan tersebut: kita cukup memberi type declaration berupa class model pada argumen handler, dan Laravel otomatis menjalankan query ke database sebelum handler dipanggil. Record yang tidak ditemukan otomatis menjadi response 404. Chapter ini membahas implicit binding, binding opsional, pencarian berdasarkan kolom non-primary-key, enum binding, sampai binding eksplisit lewat Route::model() dan kustomisasi resolusi di model.

Persiapan: Model Post dan Data Uji #

Kita pakai tabel posts dengan primary key id dan kolom slug yang unik, agar pembedaan antara binding berdasarkan id dan slug terlihat nyata. Migration-nya:

php
<?php

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::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Model Post untuk saat ini hanya mendeklarasikan kolom yang boleh diisi:

php
<?php

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['title', 'slug'];
}

Untuk pengujian, runner di bawah mengisi tiga baris data dengan nilai tetap: id 1 belajar-route-laravel, id 2 konsep-middleware, dan id 3 request-dan-response.

Implicit Binding #

Cara kerjanya sederhana: nama placeholder route harus sama dengan nama argumen handler, dan argumen tersebut di-type-hint dengan class model. Daftarkan dua route berikut di routes/web.php:

php
<?php

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

// Binding berdasarkan primary key (id)
Route::get('/postingan/{post}', function (Post $post) {
    return 'Post: '.$post->title;
});

// Binding opsional: segmen boleh absen
Route::get('/arsip/{post?}', function (?Post $post = null) {
    return $post === null
        ? 'Tidak ada post dipilih'
        : 'Arsip: '.$post->title;
});

Saat request GET /postingan/1 datang, router menangkap segmen 1, lalu Laravel menjalankan query where('id', 1)->first() sebelum handler dipanggil. Jika hasil query null, ModelNotFoundException diterjemahkan menjadi response 404 — handler tidak pernah dieksekusi. Untuk placeholder {post?}, segmen yang absen membuat proses binding dilewati sehingga handler menerima nilai default null; karena itu tipe argumennya harus nullable.

Custom Key: Mencari Berdasarkan Slug #

Binding default selalu memakai primary key. Sintaks {post:slug} mengubah kolom pencarian hanya untuk route tersebut — cocok untuk URL publik yang lebih ramah:

php
Route::get('/baca/{post:slug}', function (Post $post) {
    return 'Dibaca via slug: '.$post->title;
});

Sekarang query menjadi where('slug', 'belajar-route-laravel'). Jika ingin mengganti key default untuk semua binding model tersebut secara global, kita bisa meng-override method getKeyName() di model — tetapi hati-hati:

Enum Binding #

Untuk nilai terbatas, kita bisa mengikat segmen langsung ke backed enum. Buat app/Enums/PostStatus.php:

php
<?php

namespace App\Enums;

enum PostStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';
}

Lalu type-hint enum tersebut di handler:

php
use App\Enums\PostStatus;

Route::get('/status/{status}', function (PostStatus $status) {
    return 'Filter status: '.$status->value;
});

Laravel memanggil PostStatus::tryFrom() terhadap nilai segmen. Nilai di luar daftar case membuat request dijawab 404, sehingga handler selalu menerima case enum yang valid tanpa perlu validasi manual.

Menguji Semua Route Sekaligus #

Keempat route di atas kita uji dengan pola runner dari chapter sebelumnya, kali ini dengan data uji tiga post:

php
<?php

// uji-binding.php — jalankan dari root proyek Laravel
use App\Models\Post;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;

require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

if (Post::count() === 0) {
    Post::create(['title' => 'Belajar Route Laravel', 'slug' => 'belajar-route-laravel']);
    Post::create(['title' => 'Konsep Middleware', 'slug' => 'konsep-middleware']);
    Post::create(['title' => 'Request dan Response', 'slug' => 'request-dan-response']);
}

$kernel = $app->make(Kernel::class);

$permintaan = [
    ['GET', '/postingan/1'],
    ['GET', '/postingan/99'],
    ['GET', '/postingan/belajar-route-laravel'],
    ['GET', '/arsip'],
    ['GET', '/arsip/2'],
    ['GET', '/baca/belajar-route-laravel'],
    ['GET', '/baca/1'],
    ['GET', '/status/published'],
    ['GET', '/status/hapus-permanen'],
];

foreach ($permintaan as [$method, $uri]) {
    $response = $kernel->handle(
        Request::create('http://localhost:8000'.$uri, $method)
    );
    $body = trim($response->getContent());
    $isi = str_starts_with($body, '<!DOCTYPE') ? '(halaman error HTML)' : $body;
    echo str_pad("{$method} {$uri}", 36)
        ." → HTTP ".$response->getStatusCode()
        .' | '.$isi.PHP_EOL;
}

Output yang Dihasilkan:

text
GET /postingan/1                     → HTTP 200 | Post: Belajar Route Laravel
GET /postingan/99                    → HTTP 404 | (halaman error HTML)
GET /postingan/belajar-route-laravel → HTTP 404 | (halaman error HTML)
GET /arsip                           → HTTP 200 | Tidak ada post dipilih
GET /arsip/2                         → HTTP 200 | Arsip: Konsep Middleware
GET /baca/belajar-route-laravel      → HTTP 200 | Dibaca via slug: Belajar Route Laravel
GET /baca/1                          → HTTP 404 | (halaman error HTML)
GET /status/published                → HTTP 200 | Filter status: published
GET /status/hapus-permanen           → HTTP 404 | (halaman error HTML)

Perhatikan tiga baris penting: /postingan/99 membuktikan record hilang berujung 404, /postingan/belajar-route-laravel gagal karena binding default mencari berdasarkan id, dan /baca/1 sebaliknya gagal karena route /baca hanya menerima slug. Kedua kolom memang tidak saling menggantikan secara otomatis.

Explicit Binding dengan Route::model #

Kadang kita ingin resolusi khusus tanpa menyentuh class model — misalnya sebuah route yang menerima id maupun slug sekaligus. Binding eksplisit didaftarkan di method boot() pada app/Providers/AppServiceProvider.php:

php
<?php

// app/Providers/AppServiceProvider.php
namespace App\Providers;

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

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Route::model('artikel', Post::class, function ($value) {
            return Post::where('slug', $value)->firstOrFail();
        });
    }
}

Argumen pertama adalah nama parameter route ({artikel}), argumen kedua class model, dan argumen ketiga closure yang dijalankan sebagai fallback ketika resolusi default berdasarkan primary key gagal. Tambahkan route konsumennya:

php
Route::get('/artikel/{artikel}', function (Post $artikel) {
    return 'Artikel: '.$artikel->title;
});

Menggunakan runner yang sama, tiga request berikut diuji:

text
GET /artikel/2                   → HTTP 200 | Artikel: Konsep Middleware
GET /artikel/konsep-middleware   → HTTP 200 | Artikel: Konsep Middleware
GET /artikel/tidak-ada           → HTTP 404 | (halaman error HTML)

Nilai numerik 2 teratasi oleh mekanisme default, sedangkan slug konsep-middleware terselamatkan oleh closure fallback. Binding eksplisit ini juga diproses lebih dulu daripada implicit binding untuk nama parameter yang sama.

Kustomisasi Resolusi di Model: resolveRouteBinding #

Jika logika resolusi memang milik model, tempat terbaiknya adalah method resolveRouteBinding(). Method tersebut menerima nilai segmen dan nama kolom (terisi ketika memakai sintaks {post:slug}), lalu mengembalikan instance model atau null untuk 404. Kita ubah model Post agar mencari berdasarkan id atau slug:

php
<?php

// app/Models/Post.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['title', 'slug'];

    public function resolveRouteBinding($value, $field = null)
    {
        return $this->where($field ?? $this->getKeyName(), $value)
            ->orWhere('slug', $value)
            ->first();
    }
}

Query builder di dalam method menggabungkan dua kondisi dengan orWhere, sehingga salah satu saja yang cocok sudah cukup. Hasil ujinya dengan runner yang sama:

text
GET /postingan/1                     → HTTP 200 | Post: Belajar Route Laravel
GET /postingan/belajar-route-laravel → HTTP 200 | Post: Belajar Route Laravel
GET /postingan/99                    → HTTP 404 | (halaman error HTML)
GET /baca/konsep-middleware          → HTTP 200 | Dibaca via slug: Konsep Middleware

Baris kedua adalah bukti perubahan: URI yang sebelumnya dijawab 404 kini berhasil teratasi karena override di model berlaku untuk semua implicit binding Post.

Sebagai rangkuman, berikut panduan memilih pendekatan:

KebutuhanSolusi
Record berdasarkan primary keyImplicit binding {post}
Record berdasarkan kolom lain, per-routeCustom key {post:slug}
Resolusi khusus untuk satu nama parameterRoute::model() dengan closure
Resolusi khusus untuk semua route model tersebutOverride resolveRouteBinding()

Rincian lebih lanjut tersedia di dokumentasi routing . Sampai di sini, handler kita selalu mengandalkan parameter route dan data dari database. Padahal setiap request membawa banyak informasi lain — method, path, input, header — yang bisa kita baca lewat object Request, topik chapter berikutnya.