DEV Community

Cover image for Laravel Route Model Binding: Implicit, Explicit & Scoped
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Route Model Binding: Implicit, Explicit & Scoped

Laravel route model binding is the reason a resource controller rarely contains a manual Post::findOrFail($id) call. Part 1 covered how Laravel routing works at a basic level, and ended on the {post} parameter in a resource route resolving straight to a Post model instead of a raw ID. This part covers how that actually happens, and the handful of places it stops working the way you'd expect: custom lookup columns, nested resources, and soft-deleted rows.

Implicit binding: matching by variable name

When a controller method type-hints an Eloquent model and the parameter name matches the route segment, Laravel resolves it automatically — no manual lookup, no findOrFail():

use App\Models\Post;

Route::get('/posts/{post}', function (Post $post) {
    return $post->title;
});
Enter fullscreen mode Exit fullscreen mode

Laravel takes the {post} segment from the URL, looks up a Post whose route key matches that value, and injects the model — or throws a 404 automatically if nothing matches. The names have to line up: the route parameter is {post} and the type-hinted variable is $post. Rename one without the other and binding silently stops working; Laravel just treats the parameter as a plain string instead of raising an error, which makes it a confusing first bug to debug.

Binding by something other than the primary key

By default, "route key" means the primary key — usually id. For a public-facing URL, a slug is usually what you actually want:

// Inline, per-route:
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
Enter fullscreen mode Exit fullscreen mode

Or set it once on the model so every route binding Post uses it automatically, without repeating :slug everywhere:

class Post extends Model
{
    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}
Enter fullscreen mode Exit fullscreen mode

With that in place, /posts/{post} resolves against the slug column instead of id, across every route in the app that binds a Post — including the ones generated by Route::resource from Part 1.

Scoped bindings: making nested resources actually nest

A route like /authors/{author}/posts/{post} implies the post belongs to that author. Without telling Laravel that explicitly, it doesn't check: {post} resolves to any Post with that ID, regardless of which author owns it. That's not just a correctness bug, it's an authorization gap — it means one author's URL can be edited to show another author's post simply by changing the ID.

Route::get('/authors/{author}/posts/{post}', [PostController::class, 'show'])
    ->scopeBindings();
Enter fullscreen mode Exit fullscreen mode

scopeBindings() tells Laravel to resolve {post} through the $author's own relationship (Laravel looks for a posts() relationship method on Author) rather than querying Post directly. If the post exists but doesn't belong to that author, the route now 404s instead of silently returning the wrong record.

To apply this to every nested route in a group at once rather than one route at a time:

Route::scopeBindings()->group(function () {
    Route::get('/authors/{author}/posts/{post}', [PostController::class, 'show']);
    Route::put('/authors/{author}/posts/{post}', [PostController::class, 'update']);
});
Enter fullscreen mode Exit fullscreen mode

Nested resource routes generated by Route::resource with a nested URI need this too — it isn't applied automatically just because the URI looks nested.

Explicit binding: custom resolution logic in one place

Implicit binding covers "find by this column." For anything more specific — a different lookup column depending on context, eager-loading a relationship on every bind, excluding certain rows — define the resolution logic once, centrally, with explicit binding. As of Laravel 11, with RouteServiceProvider gone, this goes in AppServiceProvider::boot():

// app/Providers/AppServiceProvider.php
use App\Models\User;
use Illuminate\Support\Facades\Route;

public function boot(): void
{
    Route::bind('user', function (string $value) {
        return User::where('username', $value)
            ->orWhere('id', $value)
            ->firstOrFail();
    });
}
Enter fullscreen mode Exit fullscreen mode

Every route with a {user} parameter now resolves through this closure, regardless of which controller or route file it's defined in. This is the right place for binding logic that needs to be consistent everywhere — not scattered as slightly different findOrFail() calls across a dozen controller methods.

Soft-deleted models: binding skips them by default

If a model uses the SoftDeletes trait, implicit route model binding won't resolve to a soft-deleted row — it 404s, the same as if the row didn't exist at all. Usually that's exactly what you want. For an admin panel or a "restore" feature where you deliberately need to reach a trashed record by URL, opt in explicitly:

Route::get('/posts/{post}', [PostController::class, 'show'])->withTrashed();

// On a whole resource route, optionally scoped to specific actions:
Route::resource('posts', PostController::class)->withTrashed(['show']);
Enter fullscreen mode Exit fullscreen mode

One sharp edge worth knowing about here: on a scoped, nested binding where the parent model uses SoftDeletes but the child model doesn't, withTrashed() can throw a Call to undefined method ... withTrashed() error, because Laravel tries to call withTrashed() on the child relationship regardless of whether that model actually supports it. If you hit this, the fix is to add SoftDeletes to the child model too, or avoid combining scoped and trashed bindings on a parent/child pair where only one side is soft-deletable.

Custom 404 handling instead of the default response

An implicitly bound model that can't be found throws a ModelNotFoundException, which Laravel converts to a plain 404 response by default. To return something more specific — a redirect, a custom JSON error body — use missing():

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;

Route::get('/posts/{post}', [PostController::class, 'show'])
    ->missing(function (Request $request) {
        return Redirect::route('posts.index')->with('error', 'That post no longer exists.');
    });
Enter fullscreen mode Exit fullscreen mode

This also works on a whole resource route, applying to every action's missing-model case at once:

Route::resource('posts', PostController::class)
    ->missing(function (Request $request) {
        return Redirect::route('posts.index');
    });
Enter fullscreen mode Exit fullscreen mode

Quick reference

Need Use
Bind by primary key (default) Type-hint the model; parameter name must match the variable name
Bind by slug or another column {post:slug} inline, or getRouteKeyName() on the model
Ensure a nested resource belongs to its parent ->scopeBindings() on the route or route group
Centralize custom lookup logic Route::bind() in AppServiceProvider::boot() (Laravel 11+)
Allow binding to soft-deleted rows ->withTrashed(), optionally scoped to specific actions
Custom response when the model isn't found ->missing(function ($request) { ... })

Before you ship it

Check that every nested resource route (/parent/{parent}/child/{child}) actually calls scopeBindings() — it's easy to write a nested-looking URL and assume Laravel enforces the nesting, when by default it doesn't. If a model is soft-deletable and appears in a scoped binding, confirm the related model is too, or you'll hit the withTrashed() error above the first time someone restores a record. Keep explicit Route::bind() logic in one place rather than duplicating slightly different lookup queries across controllers.

Related reading: this continues from Part 1: Laravel Routing Basics.


Originally published on DEV Talk.

Top comments (0)