Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

php artisan make:controller, three ways Python · make_session.py
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:

routes/web.php, three controllers PHP · resource-probe/routes/web.php
<?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:

php artisan route:list --except-vendor Python · resource_session.py
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:

Interactive · answer and compare Choosing a controller shape Answer the two questions. The table marks the shapes that fit, and gives the reason under each one that does not.
Does the controller serve HTML pages with create and edit forms?
Does it do many things to one kind of record?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three controller shapes against four things that differ between them
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:

routes/web.php, one bound parameter PHP · lookup-probe/routes/web.php
<?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}");
Two requests for one bound batch Python · lookup_session.py
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:

Interactive · stage by stage From a segment to a Batch, or to a 404 Step through GET /batches/2, then GET /batches/9, the two requests of the run above.
One bound parameter, one query, a model or a 404, step 8 of 8. Showing GET /batches/{batch} (segment 9), SubstituteBindings, Route key of Batch, One query (no row), The response (404 Not Found). In focus: The response.GET /batches/{batch}segment 9SubstituteBindingsmiddleware of the web groupRoute key of Batchid, the defaultOne queryno rowThe response404 Not Found

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. The closure gets a model. The closure receives the Batch object, not the number, and answers 200 with Maths morning, batch 2.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Pathshala needs one endpoint that marks a fee instalment as paid and nothing else. Which controller shape fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A single-action controller made with --invokable

    A single-action controller has one method, __invoke, and fits a task that is one button, a report or a webhook.

  2. Question 2 of 2 Route::get('/batches/{batch}', fn (Batch $batch) => ...) is requested as /batches/999 and no batch has id 999. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Laravel answers 404 and the closure does not run

    A missing model ends the request with 404 before the action runs. The action never receives a model that was not found.

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:

routes/web.php, three nested routes PHP · binding-probe/routes/web.php
<?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:

Six requests against two batches Python · binding_session.py
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:

Interactive · stage by stage Two lookups, the second limited to the first Step through GET /batches/1/students/3, the request that answered 404.
How the router turns two URL segments into two models, step 5 of 5. Showing URL segments, Find the batch (Maths evening), Take its students, Find the student (not found), Run the action. In focus: Run the action.URL segments1 and 3Find the batchMaths eveningTake its studentsthe batch's relationshipFind the studentnot foundRun the actionboth models arrive

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Students 1 and 2 are in batch 1, students 3 to 5 in batch 2. A route '/batches/{batch}/students/{student}' binds by id and has no scoping. What does GET /batches/1/students/4 answer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 200, with student 4 shown under batch 1

    Unscoped bindings are independent. Add scopeBindings() or use a custom key on the child parameter so the student is searched only among the batch's students.

  2. Question 2 of 2 For the route /batches/{batch}/students/{student:roll_no} to scope the student automatically, what must exist?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A students relationship method on the Batch model

    A custom key on a nested parameter makes Laravel use the parent's relationship, found by naming convention, to find the child.

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:

routes/web.php, three ways to bind a soft-deleted student PHP · trashed-probe/routes/web.php
<?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'));
One archived student, asked for three ways Python · trashed_session.py
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:

app/Http/Controllers/PayInstalmentController.php PHP · action-probe/app/Http/Controllers/PayInstalmentController.php
<?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:

app/Actions/MarkInstalmentPaid.php PHP · action-probe/app/Actions/MarkInstalmentPaid.php
<?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:

Four tests for one controller and one action Python · action_session.py
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:

Interactive · stage by stage Who does what for one payment Step through POST /instalments/1/pay, and note which stages know about HTTP.
Where each job sits when a controller stays thin, step 5 of 5. Showing Route, Container, Controller, Action class (RCT-0001), JSON response. In focus: JSON response.Routebinds InstalmentContainerbuilds the actionControllerHTTP in and outAction classRCT-0001JSON response200 or 409

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 An admin screen restores archived students at /archive/students/{student}. The Student model uses soft deletes. What does the route need?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ->withTrashed(), together with an authorisation check

    withTrashed lets binding find soft-deleted models, and a route that does so must be protected by its own access checks.

  2. Question 2 of 2 In the lesson, who turns the DomainException from the action into a 409?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The controller

    Keeping status codes in the controller keeps HTTP out of the action, which is what lets other entry points reuse it.

Practise

Rebuild the pieces on your own Pathshala project:

  1. 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 with scopeBindings().
  2. Make a PayInstalmentController as 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.
  3. 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.

  1. Question 1 of 13 How many routes does Route::resource('batches', BatchController::class) make?

    Type a number.

    Show the answer: Question 1 of 13

    Answer: 7 routes

    Seven: index, create, store, show, edit, update and destroy, as the lesson's route list shows for batches.

  2. Question 2 of 13 Which two actions does Route::apiResource leave out compared with Route::resource?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: create and edit

    The API resource keeps five actions and drops the two that render a form, create and edit.

  3. Question 3 of 13 A public API should let clients read students but never change them. Which route line fits?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Route::apiResource('students', StudentController::class)->only(['index', 'show'])

    Restrict an API resource with only() so that the routes you do not name do not exist.

  4. Question 4 of 13 By default, which column does implicit binding search by?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: id

    The default route key is the primary key. A colon in the route, getRouteKeyName or the RouteKey attribute change it.

  5. Question 5 of 13 What does {student:roll_no} in a route do?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Binds the student by the roll_no column for that route

    A colon picks the column the lookup uses for that route, without changing the model.

  6. Question 6 of 13 Why might a public URL use a slug instead of an auto-incrementing id?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: An id tells outsiders roughly how many records exist and makes neighbouring ids easy to try

    A key built for the URL avoids showing the record count, but it does not replace authorisation.

  7. Question 7 of 13 A nested route binds the student by id. How do you make Laravel search only the batch's students?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Call scopeBindings() on the route or on a group

    scopeBindings() applies the parent-child lookup to the route even when no custom key is given.

  8. Question 8 of 13 The Batch model names its relationship pupils() instead of students(), and the route is /batches/{batch}/students/{student:roll_no}. What happens?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Laravel looks for students() on Batch, does not find it, and the request ends with a 500 error

    Scoping depends on a naming convention: the guessed method is the plural of the parameter's name. Rename the method to students(), or call the parameter {pupil:roll_no}, with the variable $pupil, so that the guess is pupils().

  9. Question 9 of 13 A route has ->missing(fn () => redirect('/students')). When does the closure run?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Whenever a bound model is not found, including a soft-deleted one

    missing handles every case where binding finds no model and replaces the 404 with your response.

  10. Question 10 of 13 A teammate adds withTrashed() to the public student page so that old links keep working. What is the risk?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Archived students become visible to everyone who has the URL

    Use withTrashed only on routes meant to reach archived records, and guard them with access checks.

  11. Question 11 of 13 The pay controller's __invoke type-hints MarkInstalmentPaid. Where does the object come from?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: The service container builds it and passes it in

    The container builds the class and the ReceiptNumber it asks for, then passes the action to the controller method.

  12. Question 12 of 13 Why does the action class not mention status codes or JSON?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: So that a command, a job or a test can call it without HTTP

    Keeping HTTP out of the action makes the rule reusable. The controller maps the result to a response.

  13. Question 13 of 13 The parents' app gets its own API controller that also marks instalments paid, and the rule is written inside the counter's controller. What will most likely happen?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The rule is copied into the API controller, and the two copies drift apart

    A rule in one class that both controllers call stays one rule. A rule inside a controller invites a copy.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 RouteKey attribute.
  • A nested route with two ids has two independent lookups, and a URL such as /batches/1/students/4 can 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, and missing() 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.