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?
answer
- each binding resolves independently by default
- custom key on the child enables scoping
- resolveChildRouteBinding via the parent relation
- relation name: plural camelCase of the segment
- scoping is not authorization
basics
~20 sBy 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.
solid answer
~40 sImplicit binding runs `Task::where('id', 99)->first()` without looking at `{project}`, so the URL's parent is decorative and a user can pair any project with any task — a classic insecure direct object reference. Calling `->scopeBindings()` on the route, or `Route::scopeBindings()->group(...)`, makes Laravel resolve each child through its preceding parent with `resolveChildRouteBinding()`: it calls the relationship named as the plural camelCase of the segment (`tasks()` for `{task}`) and runs `$project->tasks()->where('id', 99)->first()`, so a foreign task becomes a 404. A custom key on the child (`{task:slug}`) turns scoping on automatically, and `->withoutScopedBindings()` switches it off. Scoping proves the task belongs to the project; it does not prove the user may see that project — that is still a policy check.
code
php · 14 lines<?php
use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\Route;
Route::scopeBindings()->group(function () {
Route::get('/projects/{project}/tasks/{task}', function (Project $project, Task $task) {
return $task; // guaranteed to belong to $project
});
});
// app/Models/Project.php needs the relationship Laravel guesses:
// public function tasks(): HasMany { return $this->hasMany(Task::class); }go deeper
Recall that /projects/1/tasks/99 does not check that the task belongs to the project unless the route asks for scoped bindings.
Explain resolveChildRouteBinding, the plural camelCase relationship guess, and that a custom key on the child turns scoping on automatically.
Make scoping the default for nested groups, test the wrong-parent 404, and keep authorization on top of scoping.
Set a platform rule for nested resources — scoped by default, explicit opt-outs, policies on the parent — and audit existing routes against it.
## The default: independent lookups In a project tracker the URL `/projects/{project}/tasks/{task}` reads as "task 99 of project 1". Laravel's default implicit binding does **not** read it that way. Each type-hinted model is resolved on its own: - `Project $project` → `Project::where('id', 1)->first()` - `Task $task` → `Task::where('id', 99)->first()` Nothing checks that task 99 belongs to project 1. A user who can open project 1 can change the second number and read any task in the system. The parent segment is decoration unless you ask for scoping. This is an **insecure direct object reference** hiding behind a URL that looks nested. ## Turning scoping on Three things enable scoped ("child") bindings: 1. `->scopeBindings()` on a single route. 2. `Route::scopeBindings()->group(function () { ... })` for every route in a group. 3. A **custom binding field on the child segment**, such as `{task:slug}`: Laravel scopes automatically whenever a nested parameter declares its own key. With scoping on, the child is resolved through the parent: ```php // what Laravel effectively runs for /projects/1/tasks/99 $project->tasks()->where('id', 99)->first(); ``` A task from another project is simply not found, so the request ends in the usual **404**. ## How the parent and the relationship are chosen - The **parent** is the parameter immediately before the child in the URL. In `/orgs/{org}/projects/{project}/tasks/{task}`, `task` is scoped by `project`, and `project` by `org`. - The parent must itself be a bound model. - The **relationship name** is guessed as the plural camelCase of the child's segment name: `{task}` → `tasks()`, `{sub_task}` → `subTasks()`. If the parent lacks that method, the request fails with an undefined-method error rather than a 404. - The child query uses the binding field if one was given, otherwise the child model's route key. - The lookup goes through `resolveChildRouteBinding($childType, $value, $field)` on the **parent** model, which you can override for unusual relationships. - `->withTrashed()` on the route applies to the child lookup as well. ## Turning scoping off `->withoutScopedBindings()` explicitly disables scoping, including the automatic scoping a custom key would trigger. It is for routes where the child genuinely is not a child — for example `/projects/{project}/copy-task/{task:slug}`, where the task comes from a template library rather than from the project. | Route definition | Child query | |---|---| | `/projects/{project}/tasks/{task}` | `Task::where('id', ...)`, unscoped | | same, plus `->scopeBindings()` | `$project->tasks()->where('id', ...)` | | `/projects/{project}/tasks/{task:slug}` | `$project->tasks()->where('slug', ...)`, scoped automatically | | same, plus `->withoutScopedBindings()` | `Task::where('slug', ...)`, unscoped | ## Deeper and many-to-many relationships Scoping is not limited to a direct `hasMany`. When the guessed relationship is a `HasManyThrough` or a `BelongsToMany`, Laravel qualifies the child column with the related table name (`tasks.id` rather than `id`), so the join does not make the `where` ambiguous. That lets you scope, for example, `/projects/{project}/members/{member}` with a `User $member` parameter through a `members()` belongs-to-many relation: a user who is not a member of the project answers 404. Note that the relationship name comes from the segment (`{member}`), not from the model class. For three-level URLs each link is checked against its immediate predecessor, so `/orgs/{org}/projects/{project}/tasks/{task}` needs both `Org::projects()` and `Project::tasks()`, and a project from another organisation fails before the task is even looked up. ## Scoping is not authorization Scoped binding answers **"does this task belong to this project?"**. It says nothing about **"may this user see this project?"**. A user who is not a member of project 1 can still open `/projects/1/tasks/99` if task 99 really is in project 1. Keep a policy or gate check on the parent (or on the task) in addition to scoping. Conversely, a policy on the task alone may already catch cross-project access, but scoping makes the URL honest and fails fast with a 404. ## Practical guidance - Put every nested route group under `Route::scopeBindings()` so the safe behaviour is the default and opting out is visible. - Name segments after relationships (`{task}` for `tasks()`), or override `resolveChildRouteBinding()` on the parent. - Avoid an explicit `Route::bind()` for a child segment on scoped routes: explicit binders run first, implicit binding then skips the parameter, and the scope is silently lost. - Write a feature test that requests a child under the wrong parent and asserts a 404; it is the cheapest guard against a later refactor dropping the scope.
- Which relationship does Laravel call when scoping {project}/{sub_task}, and how do you change it?It pluralises the camelCase segment name, so it calls `$project->subTasks()`. If your relationship has another name, rename the segment to match it, or override `resolveChildRouteBinding($childType, $value, $field)` on the parent model and resolve the child yourself.
- If every nested route is scoped, do you still need a policy check?Yes. Scoping only proves the child belongs to the parent in the URL. It does not check that the current user may access that parent, so a non-member could still read a genuine task of a project they do not belong to. Authorize the parent or the child as well.
Unscoped binding is a librarian who fetches any book by its catalogue number, whatever shelf you named; scoped binding looks only on the shelf you named, so a book from another shelf is simply not found.
saying these in an interview costs you the question
- Nested URLs are scoped to their parent automatically in every case
- Scoped bindings make a policy check unnecessary
- withoutScopedBindings() disables implicit binding entirely
- A mismatched child returns 403 Forbidden
- Scoping uses the first parameter in the URL as the parent of every child