Route Model Binding Past the Happy Path: Custom Keys, Scoped Children and Enums

Laravel route model binding beyond ids: custom route keys, scoped nested bindings that stop cross-parent IDOR, soft deletes, backed enums and tenancy.

Steven Richardson
Steven Richardson
· 10 min read

A reviewer approved this route last month, and so did I:

Route::get('/posts/{post}/comments/{comment}', [CommentController::class, 'show']);

/posts/4/comments/9981 returns comment 9981. So does /posts/7/comments/9981. So does every other post id in the application, because Laravel resolved the comment by its own primary key and never looked at the parent in the URL. That is an insecure direct object reference with a 200 response, and it is the default behaviour of the feature everyone learns on day one.

Laravel route model binding is introduced as "type-hint the model and Laravel finds it", and most codebases stop there. The rest of it — custom keys, scoped children, soft deletes, enums, explicit resolvers — is where the security and the ergonomics actually live.

Know exactly what implicit binding resolves#

Implicit binding matches a type-hinted Eloquent parameter to the route segment of the same name, queries one column, and returns a 404 HTTP response when nothing matches. That column is whatever getRouteKeyName() returns, which defaults to the model's primary key. Every other behaviour in this article is a deliberate deviation from that one sentence.

use App\Models\User;

Route::get('/users/{user}', function (User $user) {
    return $user->email;
});

Two things are worth stating out loud, because both get assumed wrong. The lookup is a single where on one column — there is no tenancy, no soft-delete awareness beyond the global scope, and no relationship check. And the 404 is generated by the router before your controller runs, which means it happens before authorization unless you put it there yourself.

Change the route key with an attribute or a per-route override#

Two mechanisms change the column, and the trouble starts when a codebase uses both without deciding which one is the default. As of Laravel 13.21 the model-wide key is a class attribute:

use Illuminate\Database\Eloquent\Attributes\RouteKey;
use Illuminate\Database\Eloquent\Model;

#[RouteKey('slug')]
class Post extends Model
{
    // ...
}

Model::getRouteKeyName() reads that attribute by reflection and falls back to the primary key when it is absent, so overriding the method still works exactly as before — the attribute is the same opt-in sugar as #[ObservedBy], and it slots in with the rest of the attribute syntax Laravel 13 spread across the framework. Pick one style per codebase.

The per-route form overrides whatever the model says:

Route::get('/posts/{post:slug}', fn (Post $post) => $post);

My convention: if a model is addressed by slug anywhere public, put #[RouteKey('slug')] on the model and let internal routes that genuinely need the id say so explicitly. The failure mode of the reverse — model default of id, with {post:slug} sprinkled on some routes — is a model that resolves two different ways on two adjacent lines, and a cache key or a route() call that quietly disagrees with the URL in the address bar.

When neither is enough — a composite key, an encoded id, a lookup through a different table — override resolveRouteBinding($value, $field) on the model rather than reaching for a controller query.

Scope nested bindings so a child cannot cross its parent#

Back to the comment that belongs to someone else. Laravel already scopes the child automatically in one case: when the child uses a custom key.

// Scoped automatically — Laravel resolves {post} through $user->posts()
Route::get('/users/{user}/posts/{post:slug}', fn (User $user, Post $post) => $post);

The relationship name is guessed from the plural of the child parameter, so {post:slug} looks for a posts() method on User. Without a custom key you get no scoping at all, which is the trap: the route that looks identical but binds by id is the insecure one. Ask for scoping explicitly.

Route::get('/posts/{post}/comments/{comment}', [CommentController::class, 'show'])
    ->scopeBindings();

// Or for a whole group
Route::scopeBindings()->group(function () {
    Route::get('/posts/{post}/comments/{comment}', [CommentController::class, 'show']);
    Route::patch('/posts/{post}/comments/{comment}', [CommentController::class, 'update']);
});

Now /posts/7/comments/9981 resolves the comment through $post->comments() and 404s. For resource routes the equivalent is ->scoped(), which also takes the child's key: Route::resource('posts.comments', CommentController::class)->scoped(['comment' => 'uuid']). There is a matching withoutScopedBindings() for the rare route inside a scoped group that genuinely needs the unscoped lookup — make it obvious and comment why.

Lock the cross-parent 404 down with a test#

Scoping is the kind of fix that gets reverted by someone refactoring routes who does not know what the chained call was for. Write the regression test before you move on.

use App\Models\Comment;
use App\Models\Post;

it('does not resolve a comment through the wrong post', function () {
    $mine = Post::factory()->has(Comment::factory())->create();
    $theirs = Post::factory()->has(Comment::factory())->create();

    $this->actingAs($mine->author)
        ->getJson("/posts/{$mine->id}/comments/{$theirs->comments->first()->id}")
        ->assertNotFound(); // 404, not 200 with someone else's comment
});

Assert the 404, not the absence of a field in the payload. A test that checks the response body passes for the wrong reason the moment the serializer changes.

Bind soft-deleted models only where a restore needs them#

Implicit binding will not retrieve soft-deleted models — the global scope applies to the binding query like any other. That is almost always what you want, and it is exactly what breaks a restore endpoint, because the record you are restoring is by definition trashed. The route needs withTrashed():

Route::patch('/posts/{post}/restore', [PostRestoreController::class, 'update'])
    ->withTrashed();

Keep that to the restore and force-delete routes. A withTrashed() on show means a deleted post is still publicly readable by URL, which is rarely the intent and never the thing anyone notices in review. On a resource route, bare withTrashed() opts in show, edit and update together — pass an array (withTrashed(['show'])) to narrow it, and reach for softDeletableResources() only when a whole group really does want the same treatment. The equivalent surface in an admin panel is the trashed filter: see soft deletes, restore and force delete in a Filament v5 resource for how the two line up.

Type-hint a backed enum instead of writing a regex constraint#

A string-backed enum type-hinted in the route only matches valid cases; anything else is a 404 before your code runs.

namespace App\Enums;

enum Category: string
{
    case Fruits = 'fruits';
    case People = 'people';
}
use App\Enums\Category;

Route::get('/categories/{category}', function (Category $category) {
    return $category->value; // typed, already validated
});

Compare honestly with ->where('category', 'fruits|people'). The regex gives the same 404, but the controller still receives a string you have to re-validate or tryFrom(), and the list of valid values now lives in two places that drift. The enum is one source of truth, and it carries behaviour — label, colour, permission check — into the controller for free. If you are putting logic on it, enum methods, interfaces and the strategy pattern is the shape I use.

The constraint worth knowing: this is backed enums only, and in practice string-backed ones. A pure enum has no value to match a URL segment against.

Register an explicit binding for tenancy and non-Eloquent sources#

Implicit binding cannot express "and it must belong to the current team". Explicit binding can, and it keeps that rule in one file instead of in every controller:

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

public function boot(): void
{
    Route::bind('project', function (string $value) {
        return Project::query()
            ->where('team_id', auth()->user()?->current_team_id)
            ->where('uuid', $value)
            ->firstOrFail(); // 404, and never across the tenant boundary
    });
}

A cross-tenant id now 404s at the router, before any policy runs — which is the right place for a boundary that should not even acknowledge the record exists. The surrounding setup is in the complete guide to Laravel 13 multi-tenancy with teams. The same hook is how you bind something that is not Eloquent at all: an archive table, a cached projection, a value object.

Keep the closure cheap. Route caching caches route definitions, not binding logic — that closure runs on every matching request.

Customise what a missed binding returns#

missing() takes a closure that runs when an implicitly bound model cannot be found, so a dead link can redirect instead of dead-ending:

use Illuminate\Support\Facades\Redirect;

Route::get('/locations/{location:slug}', [LocationController::class, 'show'])
    ->name('locations.view')
    ->missing(fn (Request $request) => Redirect::route('locations.index'));

What does not belong here is authorization. If the answer should be 403 — the record exists and this user may not have it — that is a policy decision, and it belongs in a policy or a form request, not in a missing() handler and not in an abort(403) in the controller. Returning 404 for "exists but not yours" is a legitimate choice (it leaks less), but make it a deliberate one rather than an accident of which hook you happened to use.

Authorize the bound model in a form request#

By the time a FormRequest is resolved, binding has already run, so the model is available through the route:

class UpdateCommentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('comment'));
    }

    public function rules(): array
    {
        return ['body' => ['required', 'string', 'max:2000']];
    }
}

The ordering that matters: route middleware runs, bindings resolve (SubstituteBindings), then the controller is resolved and the form request authorizes. So a missing record is a 404 from the router and a forbidden record is a 403 from your policy — and scoping decides which of those an attacker gets to see. If these routes are a token-authenticated API, pair this with ability checks as described in the complete guide to Laravel Sanctum tokens and abilities.

Gotchas and Edge Cases#

UUID keys are not an access control. Swapping sequential ids for UUIDs or ULIDs raises the cost of enumeration and does nothing about an id that leaks from an index endpoint, an email or a webhook payload. Defence in depth, not a control. Scope the binding anyway.

scopeBindings() fails quietly on a name mismatch. The relationship is guessed from the plural of the child parameter — {comment} looks for comments(). Name the method something else and you do not get an error, you get a route that is no longer scoped. The test from earlier is what catches it.

A custom key on the child already scopes. {post:slug} under {user} is scoped whether or not you chain scopeBindings(). Knowing which routes are implicitly scoped stops you from assuming the whole file is safe because some of it is.

Route caching does not cache resolution. php artisan route:cache serialises definitions. Route::bind() closures and resolveRouteBinding() still execute per request, so an expensive resolver is an expensive request, every request.

Binding runs before your policy, not after. A 404 from a missed binding never reaches the policy, so do not rely on a policy to be the place that logs unauthorized access attempts for routes where the record simply will not resolve.

Wrapping Up#

Go through routes/ and find every route with two model parameters. Each one either has a custom key on the child, a scopeBindings(), or a bug — and the fastest way to prove which is the cross-parent test from earlier. Then decide, once, whether your public models are addressed by slug or by id, and put #[RouteKey] where that decision lives.

From here, the natural next steps are tightening what those bound models serialise into, in the complete guide to Laravel 13 JSON:API resources, and confirming the #[RouteKey] attribute is available in your version with the Laravel 12 to 13 upgrade guide.

FAQ#

How do I use a slug instead of an id in Laravel route model binding?

Either put #[RouteKey('slug')] on the model so every route resolves it by slug, or override it for a single route with {post:slug} in the route definition. The attribute landed in Laravel 13.21; overriding getRouteKeyName() to return 'slug' does the same job on earlier versions and still works. Make sure the column is unique and indexed, because the binding query hits it on every request.

What does scopeBindings do in Laravel?

It forces a nested route parameter to be resolved through the parent model's relationship instead of being looked up on its own. With ->scopeBindings() on /posts/{post}/comments/{comment}, the comment is fetched via $post->comments(), so a comment belonging to a different post returns 404. It applies to a single route or to a whole route group.

Why does my nested route return a record from the wrong parent?

Because implicit binding resolves each parameter independently by its own key, and nothing in the default behaviour ties the child to the parent in the URL. The child is only scoped automatically when it uses a custom key, such as {comment:uuid}. Add ->scopeBindings() to the route, or ->scoped() on a nested resource, and the mismatched request becomes a 404.

How do I bind a soft-deleted model in a route?

Chain withTrashed() onto the route definition — implicit binding excludes soft-deleted models by default and returns 404 instead. On a resource route, withTrashed() with no arguments opts in the show, edit and update routes, and passing an array such as withTrashed(['show']) narrows it. Restrict it to restore and force-delete endpoints so deleted records are not readable elsewhere.

Can you use an enum as a route parameter in Laravel?

Yes, if it is a backed enum. Type-hint it in the route or controller signature and Laravel invokes the route only when the segment matches a valid case, returning 404 otherwise. You get a typed enum instance in the controller rather than a string, and the list of valid values stays in one place instead of being duplicated in a regex constraint.

What is the difference between implicit and explicit route model binding?

Implicit binding is convention-based: the type-hinted parameter name matches the route segment, and Laravel queries one column and 404s on a miss. Explicit binding is registered in a service provider with Route::model() or Route::bind(), and lets you control the query completely — scoping it to a tenant, reading from an archive table, or returning something that is not an Eloquent model at all. Use implicit everywhere it fits, and explicit when resolution carries a rule you never want a controller to forget.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.