Route Model Binding Tricks You Should Be Using
Route model binding is one of those features you use constantly without thinking about it. You type-hint a Post $post in a controller and Laravel hands you the right record. But there is a surprising amount of power behind that convenience, and most developers never go past the default. Here are the tricks worth knowing.
The Default: Implicit Binding
When a route parameter name matches a type-hinted variable, Laravel resolves the model by primary key automatically and returns a 404 if it does not exist:
// routes/web.php
Route::get('/posts/{post}', [PostController::class, 'show']);
// Controller — Laravel finds Post by ID, 404s if missing
public function show(Post $post)
{
return view('posts.show', compact('post'));
}
No findOrFail(), no manual lookup. That is the baseline. Everything below builds on it.
Bind by Slug Instead of ID
URLs with IDs are ugly and leak information. Bind by any unique column by adding it to the route parameter:
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
Now /posts/my-great-article resolves the post by its slug column. To make a column the default for every binding of a model, override getRouteKeyName():
public function getRouteKeyName(): string
{
return 'slug';
}
Scope Bindings to a Parent
When a route is nested, you usually want the child to belong to the parent. Laravel can scope the binding automatically so a comment must actually belong to the given post:
// Resolves the comment ONLY if it belongs to this post
Route::get('/posts/{post}/comments/{comment}', function (Post $post, Comment $comment) {
return $comment;
});
When you use a custom key on the child, Laravel infers the relationship and scopes the query. You can also force scoping explicitly with ->scopeBindings() on the route or group, which is a one-line defense against users guessing IDs that belong to someone else's records.
Include Soft-Deleted Models When You Need To
By default, binding ignores soft-deleted records — a trashed model gives a 404. For admin "restore" screens where you specifically need the deleted record, add withTrashed():
Route::get('/admin/posts/{post}/restore', [PostController::class, 'restore'])
->withTrashed();
Customize the Missing-Model Behavior
A 404 is not always the right response. Maybe you want to redirect to an index with a flash message instead. The missing() method on the route lets you control what happens when binding fails:
Route::get('/posts/{post}', [PostController::class, 'show'])
->missing(fn () => redirect()->route('posts.index')
->with('error', 'That post no longer exists.'));
Bind Enums Directly
Laravel resolves backed enums in route parameters too. Type-hint the enum and Laravel validates the value against the enum cases automatically, 404ing on anything invalid:
enum Category: string
{
case Tech = 'tech';
case Business = 'business';
}
Route::get('/category/{category}', function (Category $category) {
return Post::where('category', $category->value)->paginate();
});
A request to /category/tech gives you Category::Tech. A request to /category/garbage gives a 404 before your code ever runs — free input validation straight from the type system.
Define Custom Resolution Logic
For anything the conventions cannot express, Route::bind() lets you write the resolution yourself:
// In a service provider's boot() method
Route::bind('user', function (string $value) {
return User::where('username', $value)
->orWhere('email', $value)
->firstOrFail();
});
Now a {user} parameter resolves by username or email. This is the escape hatch for any binding logic the framework does not handle out of the box.
Route model binding is far more than automatic findOrFail(). Custom keys clean up your URLs, scoped bindings protect your data, and enum binding hands you validated input for free. Lean on it, and your controllers shrink to just the logic that actually matters.