0

Route model binding — implicit and explicit binding

Intermediate5 min read·lv-06-005
interviewsql

Concept

Route model binding is Laravel's mechanism for automatically resolving Eloquent model instances from route parameters, so your controller receives a fully hydrated model object instead of a raw ID. It eliminates the boilerplate User::findOrFail($id) from every controller action.

Implicit binding is the zero-configuration form. When a route parameter name matches a controller method parameter name, and that parameter is type-hinted with an Eloquent model, Laravel automatically calls User::find($value) (more precisely, $model->resolveRouteBinding($value) on a fresh model instance). If the query returns null, Laravel throws Illuminate\Database\Eloquent\ModelNotFoundException, which the exception handler renders as 404. The Eloquent method called internally is resolveRouteBinding, defined in Illuminate\Database\Eloquent\Model, which calls $this->where($this->getRouteKeyName(), $value)->first(). getRouteKeyName() returns 'id' by default, but you can override it per model.

Explicit binding is configured in RouteServiceProvider::boot() via Route::bind('user', fn($value) => User::where('username', $value)->firstOrFail()). This gives you full control: you can look up by any column, apply eager loads, scope by tenant, or reject certain values. The bound closure receives the raw URI parameter value and the current Illuminate\Routing\Route instance as arguments.

You can also override getRouteKeyName() in the model to change the implicit binding column globally for that model. Laravel 7+ additionally supports scoping: Route::get('/users/{user}/posts/{post}', ...) will automatically scope the Post lookup to the authenticated User if you chain ->scopeBindings() on the route.

A common real-world pattern: APIs exposing UUID-keyed resources override getRouteKeyName() to return 'uuid'. This keeps integer IDs internal while presenting opaque identifiers in URLs, which prevents enumeration attacks and ID leakage.

Code Example

php
<?php

use App\Http\Controllers\UserController;
use App\Http\Controllers\PostController;
use App\Models\User;
use App\Models\Post;
use Illuminate\Support\Facades\Route;

// Implicit binding — parameter name {user} matches type-hint User $user
Route::get('/users/{user}', [UserController::class, 'show']);

// In UserController:
class UserController extends Controller
{
    // Laravel calls User::resolveRouteBinding($id) automatically
    public function show(User $user): JsonResponse
    {
        return response()->json($user);
    }
}

// Override the key on the model — bind by 'slug' instead of 'id'
// In app/Models/User.php:
class User extends Model
{
    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}
// Now /users/john-doe resolves: User::where('slug', 'john-doe')->first()

// Scoped implicit binding — {post} must belong to {user}
Route::get('/users/{user}/posts/{post}', [PostController::class, 'show'])
    ->scopeBindings();

// Explicit binding in RouteServiceProvider::boot()
use Illuminate\Support\Facades\Route;

public function boot(): void
{
    // Bind 'username' segment to User by username column
    Route::bind('username', function (string $value) {
        return User::where('username', $value)->firstOrFail();
    });

    // Explicit binding with eager loading
    Route::bind('post', function (string $value) {
        return Post::with('author', 'tags')->findOrFail($value);
    });
}

// Using explicit binding in a route
Route::get('/profile/{username}', [ProfileController::class, 'show']);
// $username is now a User model with username = {segment value}

// Custom resolution in the model itself (override resolveRouteBinding)
// In app/Models/Post.php:
class Post extends Model
{
    public function resolveRouteBinding(mixed $value, ?string $field = null): ?Post
    {
        return $this->where($field ?? $this->getRouteKeyName(), $value)
                    ->where('published', true)
                    ->firstOrFail();
    }
}

Interview Q&A

Q: Walk through exactly what happens internally when Laravel resolves an implicit route model binding — which classes and methods are involved?

After a route is matched, Illuminate\Routing\Router::runRouteWithinStack() calls Illuminate\Routing\ImplicitRouteBinding::resolveForRoute(). That method inspects the route's parameter names using Route::signatureParameters() — it uses PHP's ReflectionMethod to read the controller action's type hints. For each parameter that is type-hinted with a class implementing Illuminate\Contracts\Routing\UrlRoutable, it calls $model->resolveRouteBinding($value, $field). resolveRouteBinding on Illuminate\Database\Eloquent\Model executes $this->where($this->getRouteKeyName(), $value)->firstOrFail(). If the result is null, ModelNotFoundException is thrown and the handler renders a 404.


Q: What is the difference between overriding getRouteKeyName() and using an explicit Route::bind() in RouteServiceProvider?

getRouteKeyName() changes the column used for implicit binding globally for that model — every route that implicitly binds {user} will use that column. It cannot vary by route context. Route::bind() is route-parameter-level and completely replaces the lookup closure, so you can apply different logic per named parameter, add eager loads, scope by tenant, or apply any arbitrary query. Explicit binding also overrides implicit binding when both are in play for the same parameter name.


Q: How does ->scopeBindings() work for child resources, and why isn't it the default?

->scopeBindings() instructs the implicit binding resolver to constrain the child model's query using the parent model's relationship. For /users/{user}/posts/{post}, it generates a query like $user->posts()->where($post->getRouteKeyName(), $postId)->firstOrFail(). It is not the default because it requires a defined Eloquent relationship between models, adds a join/subquery overhead, and not all URL hierarchies represent actual ORM relationships. Enabling it by default would break routes where the hierarchy is purely cosmetic. You can also enable it globally for all routes by calling Route::scopeBindings() inside a group.