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?
answer
- each parameter bound on its own
- ->scoped() on the pending resource
- child resolved through the parent's relationship
- relationship name: plural of the parameter
- mismatch becomes a 404
basics
~20 sWithout 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 sOn 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
use App\Http\Controllers\BookCopyController;
use Illuminate\Support\Facades\Route;
Route::resource('books.copies', BookCopyController::class)
->scoped(['copy' => 'barcode']);go deeper
Know that nested URLs do not by themselves check that the child belongs to the parent.
Explain how scoped() resolves the child through the parent's relationship and how the relationship name is guessed.
Treat unscoped nested resources as an authorization bug, pair scoping with policies, and lock it in with a feature test.
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.