skip to content

In a Laravel nested resource books.copies, why can /books/1/copies/99 load a copy of another book, and how does scoped() fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. each parameter bound on its own
  2. ->scoped() on the pending resource
  3. child resolved through the parent's relationship
  4. relationship name: plural of the parameter
  5. mismatch becomes a 404

basics

~20 s

Without scoping, Laravel binds {book} and {copy} independently, so copy 99 loads even if it belongs to book 2. ->scoped() makes it resolve the copy through $book->copies(), and a copy outside that book yields a 404.

solid answer

~40 s

On a nested resource each route parameter is, by default, bound on its own: `{book}` becomes `Book::find(1)` and `{copy}` becomes `BookCopy::find(99)`, with no check that they are related. So `/books/1/copies/99` happily serves a copy of book 2, an object-level authorization hole. Calling `->scoped()` on the resource registration records binding fields for every parameter; implicit binding then resolves the child through the parent's relationship, `$book->copies()->where('id', 99)`, and throws `ModelNotFoundException` (a 404) when it is not there. The relationship name is guessed as the camel-cased plural of the parameter, so `{copy}` needs `Book::copies()`. `->scoped(['copy' => 'barcode'])` also switches the lookup column. Scoping proves the child belongs to the parent; it does not replace a policy check on the parent.

code

php · 7 lines
php
<?php

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

Route::resource('books.copies', BookCopyController::class)
    ->scoped(['copy' => 'barcode']);

go deeper

for a junior

Know that nested URLs do not by themselves check that the child belongs to the parent.

for a middle

Explain how scoped() resolves the child through the parent's relationship and how the relationship name is guessed.

for a senior

Treat unscoped nested resources as an authorization bug, pair scoping with policies, and lock it in with a feature test.

for a principal

Make scoped nested bindings plus policies the default rule for parent-child endpoints and enforce it in review.

## The problem A **nested resource** such as `Route::resource('books.copies', BookCopyController::class)` produces member URIs like `/books/{book}/copies/{copy}`. It looks as if the URL guarantees that the copy belongs to the book, but by default **implicit route model binding** resolves each parameter independently: 1. `{book}` = `1` → `Book` with key 1. 2. `{copy}` = `99` → `BookCopy` with key 99. Nothing compares copy 99's `book_id` with 1. A librarian authorised for book 1 can edit or delete any copy in the system by changing the last number. This is an **insecure direct object reference**: the URL claims a relationship the code never checks. ## What `scoped()` does ```php Route::resource('books.copies', BookCopyController::class)->scoped(); ``` `scoped()` stores **binding fields** on every generated route. When the router binds parameters for a route that has them, a parameter whose **previous** route parameter is a bound model is resolved **through that parent**: - The parent model's `resolveChildRouteBinding()` is called. - It looks for a relationship named after the child parameter: `Str::plural(Str::camel('copy'))` → `copies`. - It runs `$book->copies()->where(<key>, 99)->first()`. - No match throws `ModelNotFoundException`, rendered as **404**. The key column is the child's route key (normally `id`) unless you name one: ```php Route::resource('books.copies', BookCopyController::class) ->scoped(['copy' => 'barcode']); // /books/{book}/copies/{copy:barcode} ``` ## Behaviour to remember | Setup | `/books/1/copies/99` where copy 99 belongs to book 2 | |---|---| | Plain nested resource | 200, copy 99 is served | | `->scoped()` | 404 | | `->scoped(['copy' => 'barcode'])` with barcode lookup | 404 unless book 1 has a copy with that barcode | | `->shallow()` | Member route is `/copies/99`, so there is no parent to check | Details: - The relationship must exist on the parent with the guessed name (`copies` for `{copy}`). If the parameter was renamed with `parameters()`, the guess follows the new name. - Scoping only runs when the parent parameter is itself bound to a model, which is the usual case with type-hinted actions. - Collection routes (`index`, `create`, `store`) have only the parent parameter, so nothing is scoped there; the controller must still create copies **through** the book (`$book->copies()->create(...)`). ## Scoping is not authorization `scoped()` answers "does this copy belong to this book?". It does **not** answer "may this user touch this book?". A secure nested endpoint needs both: - **Scoped binding**, so the child is the parent's. - **A policy check** on the parent or child for the current user. ## Alternatives and when to pick them - **Shallow nesting** avoids the question for member routes, but then the policy must check the copy directly, since no parent is in the URL. - **Manual lookup** (`$book->copies()->findOrFail($id)`) in each action works but repeats itself and is easy to forget in one method. - **`scoped()`** centralises the rule at the route definition, so every member action gets it. ## How to prove it works 1. Write a feature test that requests `/books/{bookA}/copies/{copyOfBookB}`. 2. Assert a 404. 3. Keep the test next to the resource so removing `scoped()` breaks the build. ## Common mistakes - **Adding `scoped()` but not type-hinting the parent** in the action: when `{book}` is left as a plain string, there is no parent model to scope through. - **Assuming scoping returns 403**: an unrelated child is reported as not found, 404, which also avoids confirming that the record exists. - **Relying on scoping for `store`**: the create path has no child parameter and must attach the new copy through the parent's relationship. - **Renaming the relationship** (`Book::items()`) without renaming the placeholder, so the guessed `copies()` method no longer exists.

  • What happens if Book has no copies() relationship when the resource is scoped?
    Scoped binding calls `$book->copies()` because the relationship name is guessed as the camel-cased plural of `{copy}`. Without that method the call fails with an undefined-method error instead of a clean 404, so the relationship name must match the parameter or the model must override the child-binding lookup.
  • Does scoped() protect the store action from attaching a copy to the wrong book?
    Not by itself. `store` has only `{book}` in its URI, so there is no child to scope. Protection comes from creating through the relationship, `$book->copies()->create($data)`, and from a policy check that the user may add copies to that book.

saying these in an interview costs you the question

  • The URL /books/1/copies/99 already guarantees that copy 99 belongs to book 1.
  • scoped() is only needed when you use a custom key like a slug.
  • Once routes are scoped, no policy check is needed on nested actions.
  • scoped(['copy' => 'book_id']) is how you tell Laravel which column links child to parent.
  • A child from another parent returns 403 under scoped bindings.