/invoices/9/lines/4 should 404 when line 4 belongs to a different invoice. Without scoping, route model binding loads the line by its own id and ignores the invoice in the URL. Anyone who can guess ids can read another invoice's lines. The controller then looks correct, because both models arrived.
scoped() adds the missing constraint. The child model needs a relationship named after the parent parameter. InvoiceLine::invoice() is what {invoice} looks up.
01 What it does
Laravel resolves the invoice first, then finds the line through that relationship. A line whose invoice_id does not match never resolves, and the framework returns 404 before the controller runs.
// app/Models/InvoiceLine.php
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoiceLine extends Model
{
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
}Custom keys use scoped(['line' => 'uuid']) when the URL segment is not the primary key. The default is the key the relationship already uses.
// app/Http/Controllers/InvoiceLineController.php
use App\Models\Invoice;
use App\Models\InvoiceLine;
use Illuminate\View\View;
class InvoiceLineController
{
public function show(Invoice $invoice, InvoiceLine $line): View
{
return view('lines.show', ['invoice' => $invoice, 'line' => $line]);
}
}You do not repeat the ownership check. The line was loaded through the invoice. A test requests /invoices/{invoice}/lines/{otherInvoiceLine} and asserts 404.
02 Gotchas
- The relationship method must match the parameter name.
{invoice}looks forinvoice(), notparentInvoice(). scoped()does not check the team. An invoice the user should not see can still resolve if they know its id. Policies vs gates is that check.- A missing relationship is a 500, not a 404. The method has to exist.
- Soft-deleted lines stay hidden unless the relationship includes them. A URL to a trashed line 404s, which is usually what you want.
03 Related
One to many, without the surprises defines lines(). Policies vs gates is the 403 that scoping does not send.