skip to content

Implicit & Explicit Binding

Route model binding turns a {post} segment into a fetched model by type-hint, custom key or an explicit Route::model rule, or a 404. Interviewers probe scoped child bindings and soft deletes.

on this pageshow

explore

questions

5

In Laravel, how does implicit route model binding turn the {task} segment of /tasks/{task} into a Task model, and what happens when no row matches?

level: juniorimportance: must knowfreq 75%

answer

  1. type-hint plus matching variable name
  2. SubstituteBindings in the web and api groups
  3. resolveRouteBinding: where route key, first()
  4. ModelNotFoundException becomes a 404
  5. wrong name gives an empty model

basics

~20 s

When an action type-hints an Eloquent model and names the variable after the route segment, Laravel queries that model by its route key (the primary key by default) and injects it. No matching row throws ModelNotFoundException, rendered as a 404.

solid answer

~40 s

The `SubstituteBindings` middleware, part of the `web` and `api` groups, inspects the action's signature. For each parameter type-hinted with an Eloquent model whose **variable name matches a segment** — `Task $task` for `{task}`, or the snake_case form, so `$projectTask` matches `{project_task}` — it calls `resolveRouteBinding($value)`, which runs `where(getRouteKeyName(), $value)->first()`; the route key is the primary key unless you change it. The model replaces the string in the route's parameters and reaches the closure or controller method. If `first()` returns null, Laravel throws `ModelNotFoundException`, which the exception handler turns into a **404**. Soft-deleted rows are excluded by the `SoftDeletes` scope. If the variable name does not match any segment, no binding happens and the container injects a new, empty `Task`.

code

php · 19 lines
php
<?php

use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;

Route::get('/tasks/{task}', [TaskController::class, 'show']);

// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;

use App\Models\Task;

class TaskController
{
    public function show(Task $task) // $task matches {task}
    {
        return view('tasks.show', ['task' => $task]);
    }
}

go deeper

for a junior

Recall the two conditions — a model type-hint and a variable name matching the segment — and that a missing row gives a 404.

for a middle

Explain that SubstituteBindings calls resolveRouteBinding, which queries the route key with first(), and that ModelNotFoundException becomes the 404.

for a senior

Recognise the empty-model symptom of a misnamed parameter or a route outside the binding middleware, and keep authorization separate from existence checks.

for a principal

Decide team conventions for public identifiers and binding so that lookups, 404 behaviour and authorization stay uniform across hundreds of routes.

## What implicit binding saves you Without binding, every action that shows a record starts with the same two lines: read the id from the URL, then look the row up and fail with a 404 if it is missing. **Implicit route model binding** does both for you, driven purely by the action's signature: ```php Route::get('/tasks/{task}', function (Task $task) { return view('tasks.show', ['task' => $task]); }); ``` ## The two conditions Binding happens for a parameter only when both hold: 1. **The type-hint is an Eloquent model** (more precisely, a class implementing `Illuminate\Contracts\Routing\UrlRoutable`, which every Eloquent model does). 2. **The variable name matches a route segment**: `$task` for `{task}`. Laravel also accepts the snake_case form of the variable name, so `$projectTask` binds `{project_task}`. The position of the parameter does not matter, and other injected services can sit beside it. ## What runs, step by step The work is done by the `SubstituteBindings` middleware, which the default `web` and `api` middleware groups include. For each bindable top-level parameter it: 1. Creates an instance of the model class. 2. Calls `resolveRouteBinding($value, $field)` on it. The default implementation is `where($field ?? $this->getRouteKeyName(), $value)->first()`. 3. If a model comes back, replaces the raw string in the route's parameters with it. 4. If `null` comes back, throws `Illuminate\Database\Eloquent\ModelNotFoundException`. `getRouteKeyName()` returns the primary key name (`id`) unless the model changes it, so `/tasks/7` runs roughly `select * from tasks where id = '7' limit 1`. (A nested child on a route with scoped bindings is resolved through its parent's relationship instead; `/tasks/{task}` has no parent.) ## What happens when nothing matches `ModelNotFoundException` is not caught by your action; Laravel's exception handler converts it to a `NotFoundHttpException`, so the user sees the standard **404** page (or a JSON 404 for API requests). Two related cases: - **Soft-deleted rows** are invisible by default, because the `SoftDeletes` global scope adds `deleted_at is null`. A soft-deleted task therefore 404s too, unless the route opts in with `->withTrashed()`. - **A backed-enum type-hint** is bound the same way: a segment that is not a valid case of the enum also produces a 404. ## The two silent failure modes | Mistake | What the action receives | |---|---| | Variable named differently from the segment (`Task $item` for `{task}`) | A **new, empty `Task`** built by the container: `exists` is false and every attribute is null | | Route registered outside the `web`/`api` groups, so `SubstituteBindings` never runs | Again an empty model from the container, while the raw id sits unused in the route parameters | Both look like "the model has no data" rather than an error, which is why they cost debugging time. The fix is always to align the name or make sure the binding middleware runs. ## Where binding sits in the request Binding happens in middleware, before the action runs. That has practical consequences: - Middleware that runs **before** `SubstituteBindings` sees the raw string: `$request->route('task')` returns `"7"`. Middleware that runs **after** it sees the `Task` model. - Each bound parameter costs **one query**. The model arrives without relations; load what the view needs in the action with `$task->load('assignee')` rather than lazily in a loop. - Binding works the same for closure routes and controller methods, and the model is also available to form requests and policies that read the route parameter later. - A failed binding stops the request before any controller code, so the action can assume the model exists. ## Why it is worth using - **Less code, fewer inconsistencies**: every task route 404s the same way. - **One place to change the lookup**: the column, soft-delete handling and scoping are declared on the route or the model instead of repeated in each action. - **Links follow the same key**: passing the model to `route()` uses its route key, so lookups and generated URLs agree. Implicit binding only answers "does this record exist?". It does **not** answer "may this user see it?" — authorization is a separate step.

  • A controller method declares show(Task $item) for the route /tasks/{task}. What does $item contain?
    A new, empty `Task`. Binding matches on the variable name, so `$item` is not tied to `{task}`; the router then resolves the type-hinted class from the container, which instantiates a fresh model with `exists` false. Rename the parameter to `$task` to get the bound record.
  • How does implicit binding treat a PHP backed enum type-hinted in a route action?
    Laravel calls the enum's `tryFrom()` with the segment value and injects the case. If no case matches, it throws `BackedEnumCaseNotFoundException`, which the exception handler renders as a 404 — so `/tasks/status/{status}` with `TaskStatus $status` only reaches the action for valid values.

saying these in an interview costs you the question

  • Binding works by type-hint alone, whatever the variable is called
  • A missing row injects null into the action
  • A missing model produces a 500 error
  • Implicit binding checks that the user may view the record
  • Soft-deleted rows are bound like any other row
open as a page

In a Laravel route /projects/{project}/tasks/{task}, why can a user load another project's task, and how do scopeBindings() and withoutScopedBindings() change that?

level: seniorimportance: must knowfreq 45%

basics

~20 s

By default each implicit binding is resolved on its own, so /projects/1/tasks/99 loads task 99 even if it belongs to project 2. scopeBindings() resolves the task through $project->tasks(), turning a mismatch into a 404; withoutScopedBindings() turns that scoping off.

open as a page

In Laravel 13, how do {project:slug}, getRouteKeyName() and the #[RouteKey] attribute change which column route model binding queries?

level: middleimportance: should knowfreq 50%

basics

~20 s

{project:slug} makes one route query the slug column. getRouteKeyName() sets the default column for every binding of that model; in Laravel 13 the #[RouteKey('slug')] class attribute does the same declaratively. Without either, the primary key is used.

open as a page

In Laravel, when would you use Route::model(), Route::bind() or an overridden resolveRouteBinding() instead of plain implicit route model binding?

level: middleimportance: should knowfreq 35%

basics

~20 s

Route::model() binds a segment name to a model class even without a type-hint; Route::bind() runs your own closure for a segment and can return anything; overriding resolveRouteBinding() changes the lookup for every top-level binding of that model.

open as a page

In Laravel, how do withTrashed(), missing() and a backed-enum route parameter change what a bound route does when the lookup fails or finds a soft-deleted row?

level: middleimportance: should knowfreq 30%

basics

~20 s

Soft-deleted models 404 unless the route calls withTrashed(). missing() swaps the not-found 404 for your own response, such as a redirect. A backed-enum parameter binds only valid cases; anything else is a 404 that missing() does not intercept.

open as a page