Building Web Apps with Laravel Module 3 – Requests, routing and middleware
Controllers and route model binding
Choose a controller shape, let Laravel 13 turn URL ids into models, scope nested bindings so one parent cannot show another's child, and keep controllers thin.
What you will learn
- Choose between a single-action, a resource and an API resource controller for a piece of work
- Predict which model implicit route model binding finds for a URL, and when it answers 404
- Limit a nested binding so a child is found only inside its parent
- Load soft-deleted models with withTrashed and replace the 404 with missing
- Move a business rule out of a controller into an action class that the container builds
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between a single-action, a resource and an API resource controller for a piece of work
- Predict which model implicit route model binding finds for a URL, and when it answers 404
- Limit a nested binding so a child is found only inside its parent
- Load soft-deleted models with withTrashed and replace the 404 with missing
- Move a business rule out of a controller into an action class that the container builds
Before you start
On this page
Pathshala has two batches with the same roll numbers. In Maths evening, roll 1 is Asha. In Maths morning, roll 1 is Chetan. A page at /batches/1/students/4 that finds students by id alone shows Divya, who is in the morning batch, under the heading of the evening batch, and nothing in the code looks wrong. The mistake is in how the URL turns into models.
That step is route model binding, and the class around it is the controller. You will generate three shapes of controller, run a nested route that leaks one batch’s student into another and then close the leak, see what happens to a soft-deleted student, and move a payment rule out of a controller into a class that a command or a job can call as well.
Choose the controller shape that matches the work
A controller: A class, by default in app/Http/Controllers, whose public methods (actions) answer requests; an action gathers the data a page needs and returns a view. More in “Your first page: route, controller and view” is a class whose public methods answer routes. Artisan generates three shapes, and the shape you pick decides how many routes and methods you start with. Generate each one in a new project and list their methods:
from session import run
# Generate three kinds of controller in a new project and look at the actions each one gets.
run(
"""
php artisan make:controller BatchController --resource
php artisan make:controller StudentController --api
php artisan make:controller FeeReportController --invokable
grep -n "public function" app/Http/Controllers/BatchController.php app/Http/Controllers/StudentController.php app/Http/Controllers/FeeReportController.php
""",
app=True,
) Output
$ php artisan make:controller BatchController --resource INFO Controller [app/Http/Controllers/BatchController.php] created successfully. $ php artisan make:controller StudentController --api INFO Controller [app/Http/Controllers/StudentController.php] created successfully. $ php artisan make:controller FeeReportController --invokable INFO Controller [app/Http/Controllers/FeeReportController.php] created successfully. $ grep -n "public function" app/Http/Controllers/BatchController.php app/Http/Controllers/StudentController.php app/Http/Controllers/FeeReportController.php app/Http/Controllers/BatchController.php:12: public function index() app/Http/Controllers/BatchController.php:20: public function create() app/Http/Controllers/BatchController.php:28: public function store(Request $request) app/Http/Controllers/BatchController.php:36: public function show(string $id) app/Http/Controllers/BatchController.php:44: public function edit(string $id) app/Http/Controllers/BatchController.php:52: public function update(Request $request, string $id) app/Http/Controllers/BatchController.php:60: public function destroy(string $id) app/Http/Controllers/StudentController.php:12: public function index() app/Http/Controllers/StudentController.php:20: public function store(Request $request) app/Http/Controllers/StudentController.php:28: public function show(string $id) app/Http/Controllers/StudentController.php:36: public function update(Request $request, string $id) app/Http/Controllers/StudentController.php:44: public function destroy(string $id) app/Http/Controllers/FeeReportController.php:12: public function __invoke(Request $request)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 make_session.py
--resource gives seven methods: index, create, store, show, edit, update and destroy. --api gives five and drops create and edit, the two that show a form, because a client that sends JSON has no form to show. --invokable gives one method, __invoke, and the class is a single-action controller: A controller class with one method, __invoke, for one task such as a report or a payment button. The route names the class alone.. The routes file ties them to URLs, a resource controller: A controller with one method for each operation of a resource (index, create, store, show, edit, update and destroy), which Route::resource maps to seven routes. More in “Your first CRUD feature with Eloquent” with one line:
<?php
use App\Http\Controllers\BatchController;
use App\Http\Controllers\FeeReportController;
use App\Http\Controllers\StudentController;
use Illuminate\Support\Facades\Route;
// Seven routes from one line: index, create, store, show, edit, update, destroy.
Route::resource('batches', BatchController::class);
// An API resource drops the two HTML form routes (create and edit), and only() keeps the actions you name.
Route::apiResource('students', StudentController::class)->only(['index', 'show']);
// A single-action controller is registered by its class alone.
Route::get('/reports/fees', FeeReportController::class)->name('reports.fees'); Route::resource('batches', BatchController::class) makes seven routes and apiResource makes five. only() keeps the actions you name, and except() drops them. A single-action controller is registered by its class alone. Read what those three calls made:
from session import run
# The generated controllers and the routes file of the lesson are in place; list what the three route calls made.
run(
"""
php artisan route:list --except-vendor
""",
app=True,
overlay=("resource-probe",),
) Output
$ php artisan route:list --except-vendor
GET|HEAD batches .............. batches.index › BatchController@index
POST batches .............. batches.store › BatchController@store
GET|HEAD batches/create ..... batches.create › BatchController@create
GET|HEAD batches/{batch} ........ batches.show › BatchController@show
PUT|PATCH batches/{batch} .... batches.update › BatchController@update
DELETE batches/{batch} .. batches.destroy › BatchController@destroy
GET|HEAD batches/{batch}/edit ... batches.edit › BatchController@edit
GET|HEAD reports/fees ............ reports.fees › FeeReportController
GET|HEAD students .......... students.index › StudentController@index
GET|HEAD students/{student} .. students.show › StudentController@show
Showing [10] routes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 resource_session.py
The batches resource made seven routes, with the parameter named after the resource, {batch}. The students API resource with only(['index', 'show']) made two. The report controller made one route whose action is just the class name. Choose by the work the class does:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Shape | Actions it holds | Routes from one line | HTML form pages | Fits best |
|---|---|---|---|---|
| Single action Made with: make:controller Name --invokable | Good one method, __invoke | Mixed one route that you write yourself | Mixed one page, or one form post | Good a report, a webhook, one button such as pay |
| Resource Made with: make:controller Name --resource | Good seven: index, create, store, show, edit, update, destroy | Good Route::resource makes seven routes | Good create and edit are the form pages | Good a full set of pages for one kind of record |
| API resource Made with: make:controller Name --api | Mixed five: no create and no edit | Good Route::apiResource makes five routes | Poor none, because a client sends JSON | Good a JSON interface for an app or a script |
How the answers decide
- “Does the controller serve HTML pages with create and edit forms?” Yes, pages with forms and “Does it do many things to one kind of record?” Yes, list, show and change: rules out Single action and API resource. A set of form pages for one record is what the seven resource actions are for, so the other two shapes leave most of them out.
- “Does the controller serve HTML pages with create and edit forms?” No, JSON or one task and “Does it do many things to one kind of record?” Yes, list, show and change: rules out Resource. A client that sends JSON has no use for create and edit pages, so the five-route API resource is the smaller fit.
- “Does it do many things to one kind of record?” No, one task only: rules out Resource and API resource. One task needs one method. A resource controller would leave six empty actions for it.
Do not generate a resource controller for work that is one button. Six empty methods are six places for a reader to wonder whether something was forgotten.
Implicit binding turns a URL segment into a model or a 404
The probe project has two batches: Maths evening with id 1 and Maths morning with id 2. Give a route one parameter typed with the Batch model, then ask for an id that exists and one that does not:
<?php
use App\Models\Batch;
use Illuminate\Support\Facades\Route;
// One bound parameter: Laravel finds the batch whose id is the URL segment before the closure runs.
Route::get('/batches/{batch}', fn (Batch $batch) => "{$batch->name}, batch {$batch->id}"); from session import run
# One bound parameter. The request script makes batch 1 (Maths evening) and batch 2 (Maths morning); there is no batch 9.
run(
"""
php artisan migrate --force
php lookup_requests.php /batches/2 /batches/9
""",
app=True,
overlay=("binding-probe", "lookup-probe"),
) Output
$ php artisan migrate --force INFO Running migrations. 2026_01_01_000001_create_batches_table ................................ DONE 2026_01_01_000002_create_students_table ............................... DONE $ php lookup_requests.php /batches/2 /batches/9 GET /batches/2 200 Maths morning, batch 2 GET /batches/9 404 ModelNotFoundException
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_session.py
/batches/2 answered 200 with Maths morning, so the closure received a Batch object and not the text 2. /batches/9 answered 404 and the closure never ran. This is route model binding: Laravel's habit of turning a URL segment into an Eloquent model before the action runs: it looks the row up by the route key and answers 404 when none is found.: when a parameter is type-hinted with an Eloquent model and its name matches the placeholder, Laravel queries for the model whose key equals the segment and passes the model in. When no row matches, the lookup throws the ModelNotFoundException that the run printed, and Laravel’s exception handler turns it into 404 Not Found. A controller action such as public function show(Batch $batch) is bound the same way. Without binding, every action would repeat the query, the 404 check and the variable. Follow both requests through the binding:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The route matches. The router picks the route GET /batches/{batch} and holds the raw text 2 for the batch parameter. Nothing has been read from the database yet.
- Name and type agree. The closure's parameter is called $batch, like the placeholder, and its type is the Eloquent model Batch. The SubstituteBindings middleware of the web group binds such parameters before the closure runs.
- The key is chosen. The placeholder has no colon, and Batch has neither a RouteKey attribute nor its own getRouteKeyName method, so the lookup uses the primary key, id.
- A model comes back. One query asks for the first batch whose id is 2. The run made batch 2 Maths morning, so a Batch object is found and replaces the text 2.
- The closure gets a model. The closure receives the Batch object, not the number, and answers 200 with Maths morning, batch 2.
- Now GET /batches/9. The second request goes through the same steps with the segment 9. The route, the middleware and the key are the same as before.
- No row, so no model. No batch has id 9, so the query returns nothing and the binding throws ModelNotFoundException, the exception the run printed.
- The answer is 404. Laravel's exception handler turns ModelNotFoundException into 404 Not Found. The closure is never called, so it never has to check for a missing batch itself.
The key it searches by is id unless you say otherwise. A route key: The column route model binding searches by. It is the model's id unless a colon in the route, getRouteKeyName or the RouteKey attribute names another. can be changed in three ways. A colon in the route, {student:roll_no}, picks a column for that route. A getRouteKeyName method on the model changes it for every route, and so does the RouteKey attribute on the model class, #[RouteKey('slug')]. A public URL often prefers a slug to an id, because an id reveals how many records exist. A roll number cannot stand alone in Pathshala, since roll 1 exists in both batches; it identifies a student only under its batch, which is the next section’s case.
Binding finds one row and runs one query
Each bound parameter usually costs one query before your code starts. A route with two bound models runs two lookups, which the next section uses to make the second depend on the first.
Checkpoint
Check: Single-action, resource and API resource controllers · Implicit route model binding and its 404
A nested route must be scoped, or the URL can mix up two parents
Now the problem from the start of the lesson. The same two batches get five students, and roll numbers repeat between them. Students 1 and 2 are in Maths evening, students 3, 4 and 5 in Maths morning. Here are three ways to bind the same nested URL:
<?php
use App\Models\Batch;
use App\Models\Student;
use Illuminate\Support\Facades\Route;
// 1. Bound by id, with nothing tying the student to the batch in the URL.
Route::get('/loose/batches/{batch}/students/{student}', fn (Batch $batch, Student $student) => "{$student->name} (roll {$student->roll_no}) in {$batch->name}");
// 2. A custom key on the child parameter makes Laravel look for the student among the batch's students.
Route::get('/batches/{batch}/students/{student:roll_no}', fn (Batch $batch, Student $student) => "{$student->name} (roll {$student->roll_no}) in {$batch->name}");
// 3. The same scoping for an id, asked for explicitly.
Route::get('/scoped/batches/{batch}/students/{student}', fn (Batch $batch, Student $student) => "{$student->name} (id {$student->id}) in {$batch->name}")
->scopeBindings(); The first route binds {student} by id and does nothing to tie it to {batch}. The second uses {student:roll_no}. The third binds by id and calls scopeBindings(). Send six requests through the real kernel:
from session import run
# Two batches with overlapping roll numbers. The ids of the students are 1 and 2 (evening), then 3, 4 and 5 (morning).
run(
"""
php artisan migrate --force
php binding_requests.php /loose/batches/1/students/4 /batches/1/students/1 /batches/2/students/1 /batches/1/students/3 /scoped/batches/1/students/4 /scoped/batches/2/students/4
""",
app=True,
overlay=("binding-probe",),
) Output
$ php artisan migrate --force INFO Running migrations. 2026_01_01_000001_create_batches_table ................................ DONE 2026_01_01_000002_create_students_table ............................... DONE $ php binding_requests.php /loose/batches/1/students/4 /batches/1/students/1 /batches/2/students/1 /batches/1/students/3 /scoped/batches/1/students/4 /scoped/batches/2/students/4 GET /loose/batches/1/students/4 200 Divya (roll 2) in Maths evening GET /batches/1/students/1 200 Asha (roll 1) in Maths evening GET /batches/2/students/1 200 Chetan (roll 1) in Maths morning GET /batches/1/students/3 404 GET /scoped/batches/1/students/4 404 GET /scoped/batches/2/students/4 200 Divya (id 4) in Maths morning
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 binding_session.py
The first line is the leak. /loose/batches/1/students/4 answered 200 with Divya, “in Maths evening”, although she is in the morning batch. The router bound batch 1 and student 4 independently, both existed, and the page printed them together. The second and third lines show roll numbers working: roll 1 in batch 1 is Asha, and roll 1 in batch 2 is Chetan. The fourth line, roll 3 in batch 1, answered 404, because batch 1 has no roll 3.
The last two lines show scopeBindings() for ids. Student 4 under batch 1 answered 404, and the same student under batch 2 answered 200. Step through how the router reached the 404 in the fourth line:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The URL is split. The route is /batches/{batch}/students/{student:roll_no}. For GET /batches/1/students/3 the router holds two raw values, 1 for batch and 3 for student, before any query has run.
- The batch is looked up. The first parameter is type-hinted with Batch and uses the default key, id, so Laravel looks for the batch whose id is 1. It exists, and the route goes on.
- Query stays inside the batch. The student parameter has a custom key, roll_no, so Laravel looks inside the batch's students relationship and not in the whole students table. The name of the relationship method is the plural of the parameter name.
- The student is looked up. The query asks for roll_no 3 among the students of batch 1. That batch has only rolls 1 and 2, so nothing is found.
- A missing model is a 404. When a model is not found, the binding throws ModelNotFoundException and Laravel answers 404 Not Found. The action does not run, and no student from the other batch is shown. For /batches/2/students/1 the same steps find Chetan, and the action runs with both models.
A custom key on a nested parameter turns scoped binding: A nested binding that looks for the child only among the parent's children, so a URL cannot pair a child with a parent it does not belong to. on for that parameter without any further call. Laravel then finds the child through a relationship on the parent, and it guesses the relationship’s name as the plural of the parameter’s name. Here that is Batch::students(), so the method has to exist with that name. An id-based nested route needs scopeBindings() on the route or on a Route::scopeBindings()->group(...), and withoutScopedBindings() turns it off again.
Key idea
A nested route with two ids has two independent lookups unless you scope the second one. Scoping makes the child be searched only among the parent’s children, so a URL cannot join two records that are not related.
The leak is a correctness bug first and a privacy bug second.
The loose route is an authorisation bug as well
If batches belonged to different centres, the loose route would show a student of one centre under a URL of another. Scoping is a correctness check, and the access checks of the authorisation module still come on top.
Checkpoint
Check: Scoped bindings for nested routes
Soft-deleted models need withTrashed, and missing replaces the 404
A model that uses soft delete: Marking a row as deleted with a deleted_at time instead of removing it. Normal queries and route binding skip such rows, and they can be restored. keeps its row when you call delete(), and sets a deleted_at time. Route model binding does not find such a model. That is the right default for a public page, and the wrong one for an admin screen that restores archived students. The probe soft-deletes one student, Farah, and asks three routes for her:
<?php
use App\Models\Student;
use Illuminate\Support\Facades\Route;
// The default: a soft-deleted student is not found.
Route::get('/students/{student}', fn (Student $student) => $student->name);
// withTrashed lets the same binding find archived students, for screens that restore them.
Route::get('/archive/students/{student}', fn (Student $student) => $student->name.($student->trashed() ? ' (archived)' : ''))
->withTrashed();
// missing replaces the 404 with a response of your own when no model is found.
Route::get('/safe/students/{student}', fn (Student $student) => $student->name)
->missing(fn () => redirect('/students')); from session import run
# One soft-deleted student, asked for through three routes, and an id that never existed.
run(
"""
php artisan migrate --force
php trashed_requests.php /students/1 /archive/students/1 /safe/students/1 /safe/students/99
""",
app=True,
overlay=("binding-probe", "trashed-probe"),
) Output
$ php artisan migrate --force INFO Running migrations. 2026_01_01_000001_create_batches_table ................................ DONE 2026_01_01_000002_create_students_table ............................... DONE $ php trashed_requests.php /students/1 /archive/students/1 /safe/students/1 /safe/students/99 GET /students/1 404 GET /archive/students/1 200 Farah (archived) GET /safe/students/1 302 to http://localhost/students GET /safe/students/99 302 to http://localhost/students
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trashed_session.py
The plain route answered 404. The route with withTrashed() answered 200, and the action could tell that she was archived with $student->trashed(). The route with missing() answered 302 to the student list, and it did so for student 99, who never existed, as well as for archived Farah. The missing closure runs whenever binding finds nothing, whatever the reason, so an old link to a student who is archived or was never there can send the visitor somewhere useful instead of to an error page.
Use withTrashed() only on routes that are meant to reach archived records, and put an authorisation check on them. A restore screen that anyone can open would publish the data that a soft delete was meant to hide.
A thin controller delegates, and the container builds what it needs
A controller should do the work that is about HTTP: read the input, call something that knows the rule, and choose the response. The rule itself, here that an instalment is paid once, belongs in a class with no HTTP in it. A common form is the action class: A plain class with one public method, often handle, that holds one business rule and knows nothing of HTTP, so controllers, commands and jobs can all call it., a class with one public method, often handle. It is a team convention and not a feature of the framework, and its value is that a controller, an Artisan command, a queued job and a Livewire component can all call the same code.
The classes do not build each other. Laravel’s container does it through dependency injection: Giving a class the objects it needs instead of building them inside. Laravel's container builds a class named in a constructor or a method and passes it in.: when a controller’s constructor or a method names a class as a type, the container builds an instance and passes it in, building that class’s own dependencies first. Here is the pay controller of the probe project:
<?php
namespace App\Http\Controllers;
use App\Actions\MarkInstalmentPaid;
use App\Models\Instalment;
use DomainException;
use Illuminate\Http\JsonResponse;
// A single-action controller. The route binds $instalment and the container builds $mark.
class PayInstalmentController extends Controller
{
public function __invoke(Instalment $instalment, MarkInstalmentPaid $mark): JsonResponse
{
try {
$paid = $mark->handle($instalment);
} catch (DomainException $e) {
return response()->json(['message' => $e->getMessage()], 409);
}
return response()->json(['receipt' => $paid->receipt_no]);
}
} Its single method receives the bound Instalment from the route and a MarkInstalmentPaid from the container. The action holds the rule and the receipt service:
<?php
namespace App\Actions;
use App\Models\Instalment;
use App\Support\ReceiptNumber;
use DomainException;
// One job, one public method. Nothing here knows about HTTP, so a controller, a command or a job can call it.
class MarkInstalmentPaid
{
// The container passes the ReceiptNumber service in when it builds this class.
public function __construct(private ReceiptNumber $receipts)
{
}
public function handle(Instalment $instalment): Instalment
{
if ($instalment->paid_at !== null) {
throw new DomainException('This instalment is already paid.');
}
$instalment->update([
'paid_at' => now(),
'receipt_no' => $this->receipts->for($instalment->id),
]);
return $instalment;
}
} The action’s constructor asks for a ReceiptNumber, so the container builds that first. The action throws a DomainException for a second payment and knows nothing about status codes, and the controller turns that into 409. Four tests check the pieces, and the last one calls the action with no HTTP at all:
from session import run
# One controller, one action class and a service the container builds, checked by four tests.
run(
"""
php artisan test --filter=PayInstalmentTest
""",
app=True,
overlay=("action-probe",),
) Output
$ php artisan test --filter=PayInstalmentTest PASS Tests\Feature\PayInstalmentTest ✓ posting marks the instalment paid and returns the receipt …s ✓ the second post is refused …s ✓ an instalment that does not exist is a 404 …s ✓ the action works without http …s Tests: 4 passed (8 assertions) Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 action_session.py
All four pass. Step through one payment and watch which stage knows about HTTP:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The route binds the model. The POST reaches the pay route. Route model binding turns the 1 in the URL into an Instalment, or answers 404 if there is none, as the third test checks.
- The container builds the action. The controller's __invoke method type-hints MarkInstalmentPaid, so the container builds it. The action's own constructor asks for a ReceiptNumber, which the container builds first.
- The controller takes the request. The controller does only the work that is about HTTP. It calls the action with the model and turns what comes back into a response.
- The action decides. The action holds the rule that an instalment is paid once. It sets paid_at and the receipt number, or throws a DomainException that does not mention HTTP.
- The response is chosen. The controller returns the receipt as JSON with status 200, and catches the exception to return 409 for a second payment. The action can also be called from a command or a job.
Business rules in the controller
A controller that checks the payment state, writes the columns and builds the receipt number holds three jobs. The next entry point that marks an instalment paid, an API for a parents’ app or a queued job, has to copy all of it, and the copy will disagree one day. Keep the rule in one class and let each entry point call it.
Checkpoint
Check: Soft-deleted models, withTrashed and missing · Thin controllers, action classes and injection
Practise
Rebuild the pieces on your own Pathshala project:
- Add
GET /students/{student}/guardians/{guardian}with a guardian that belongs to a student. Seed two students with one guardian each, request the wrong pairing, and make it answer 404 withscopeBindings(). - Make a
PayInstalmentControlleras in this lesson, put the rule in an action class, and write a feature test for the first payment, the second payment and an id that does not exist. - Soft-delete a student and add an archive route with
withTrashed()that says whether the student is archived. Write a feature test that the public/students/{student}route answers 404 for that student while the archive route answers 200. Before such a route goes live it needs an access check of its own, which the authorisation module adds.
Then work through the self-check.
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does route model binding do, and when does it answer 404?
When a route parameter is type-hinted with an Eloquent model and has the same name as the placeholder in the path, Laravel runs a query for the model whose route key equals the URL segment and hands the model to the action. The key is the id unless the route, the model’s route key name or the RouteKey attribute names another column. If the query finds nothing, the lookup throws ModelNotFoundException, Laravel answers 404 and the action never runs. By default a soft-deleted model is not found either. The same lookup can be limited to a parent’s children with scoped bindings, so a nested URL cannot join two unrelated records, and a route can customise the not-found answer with the missing method.
Three longer questions grow out of these rules:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Pathshala's staff pages use /centres/{centre}/batches/{batch}/students/{student:roll_no}. A tester opens /centres/1/batches/7/students/2 and sees a batch of centre 2 under centre 1, though a wrong roll number gives 404. Explain why only part of the URL is scoped, then prove the bug in a test and fix the route.
-
Fresher to mid
The counter's pay controller holds the whole rule: refuse a paid instalment, set paid_at, build the receipt. A nightly command must now mark 1,000 bank-confirmed instalments paid and skip those already paid at the counter, while a cashier may press Pay for one of them at the same moment. Restructure the code, and stop a double payment.
-
Mid to senior
Staff need to open and restore archived students. A teammate adds ->withTrashed() to Route::resource('students', StudentController::class), whose actions type-hint Student. Say which of the seven routes now reach archived students, what that exposes, and how you would build the archive and the restore instead.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which controller shape fits which job?
A single-action controller fits one task; a resource controller fits the full set of seven pages for one kind of record; an API resource controller has five actions, dropping create and edit, the form pages a client that sends JSON never needs.
-
What does route model binding do with a URL segment, and when does it answer 404?
When an action's parameter is type-hinted with a model and named like the placeholder, Laravel looks the model up by its route key and passes it in. If no row matches, the action never runs and the response is 404.
-
How do you make route model binding look a model up by a column other than its id?
Name the column after a colon in the route, as in {batch:code}, set the model's route key name, or put the RouteKey attribute on the model.
-
Why can /batches/1/students/4 show a student who belongs to another batch?
Without scoping, the two ids are two independent lookups. Scoping the child through the parent, with a custom key on the nested parameter or scopeBindings(), searches the student only among batch 1's students and answers 404 otherwise.
-
How does route model binding treat a soft-deleted model?
It does not find it, so the route answers 404. withTrashed() lets one route find archived rows, which then needs its own access check, and missing() replaces the 404 with a response of your choice whenever no model is found.
-
What belongs in a thin controller, and where does the rule go?
The controller reads the input and chooses the response. The rule lives in an action class that the container builds and injects, so an Artisan command or a queued job can call the same action.
Key takeaways
- A single-action controller fits one task, a resource controller fits a full set of pages for one kind of record, and an API resource controller drops the two form pages for a client that sends JSON.
- Route model binding turns a URL segment into a model by its route key, answers 404 when no row is found, and lets you change the key with a colon in the route, the model, or the
RouteKeyattribute. - A nested route with two ids has two independent lookups, and a URL such as
/batches/1/students/4can show a student of another batch unless the second binding is scoped to the first. - A custom key on a nested parameter scopes the binding through the parent’s relationship,
scopeBindings()does the same for ids, and in the probe the scoped routes answered 404 for a student under the wrong batch. - Binding skips soft-deleted models,
withTrashed()lets one route find them and calls for an access check on that route, andmissing()replaces the 404 whenever no model is found, archived or never there. - A thin controller reads the input and chooses the response, an action class holds the rule, and the container builds both through dependency injection, so a command or a job can call the same action.
Terms in this lesson
- action class
- A plain class with one public method, often handle, that holds one business rule and knows nothing of HTTP, so controllers, commands and jobs can all call it.
- dependency injection
- Giving a class the objects it needs instead of building them inside. Laravel's container builds a class named in a constructor or a method and passes it in.
- route key
- The column route model binding searches by. It is the model's id unless a colon in the route, getRouteKeyName or the RouteKey attribute names another.
- route model binding
- Laravel's habit of turning a URL segment into an Eloquent model before the action runs: it looks the row up by the route key and answers 404 when none is found.
- scoped binding
- A nested binding that looks for the child only among the parent's children, so a URL cannot pair a child with a parent it does not belong to.
- single-action controller
- A controller class with one method, __invoke, for one task such as a report or a payment button. The route names the class alone.
- soft delete
- Marking a row as deleted with a deleted_at time instead of removing it. Normal queries and route binding skip such rows, and they can be restored.
References
- Controllers (Laravel (laravel.com))
- Routing, route model binding (Laravel (laravel.com))
- Service container (Laravel (laravel.com))
- Eloquent, soft deleting (Laravel (laravel.com))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress