skip to content

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