skip to content

In Laravel, when would you write a single-action controller with __invoke, and how do you generate and route it?

level: middleimportance: should knowfreq 55%

answer

  1. one class, one job
  2. make:controller --invokable (-i)
  3. __invoke(Request $request)
  4. route gets the class name alone
  5. no __invoke: Invalid route action

basics

~20 s

A single-action controller has one __invoke method, generated with make:controller Name --invokable. Routes pass just the class, Route::post('/rentals/{rental}/return', ReturnRental::class); use it for an action that is not CRUD or deserves its own class and dependencies.

solid answer

~40 s

A **single-action** (invokable) controller is a class whose only action is `__invoke`. `php artisan make:controller ReturnRental --invokable` (short `-i`) writes one with `public function __invoke(Request $request)`. In the routes file you pass the class alone, `Route::post('/rentals/{rental}/return', ReturnRental::class)`; the router sees a string without `@`, checks that `__invoke` exists and stores the action as `ReturnRental@__invoke`, throwing `UnexpectedValueException` (*Invalid route action*) while routes load if it does not. I reach for one when an endpoint is a verb that does not fit the resource actions (return a car, extend a rental, export an invoice), when one action needs its own set of constructor dependencies, or when a team prefers one class per use case.

code

php · 21 lines
php
<?php

namespace App\Http\Controllers;

use App\Models\Rental;
use App\Services\RentalReturns;
use Illuminate\Http\RedirectResponse;

class ReturnRental extends Controller
{
    public function __construct(private RentalReturns $returns)
    {
    }

    public function __invoke(Rental $rental): RedirectResponse
    {
        $this->returns->close($rental);

        return to_route('rentals.show', $rental);
    }
}

go deeper

for a junior

Recall the --invokable flag, the __invoke method, and that the route passes only the class name.

for a middle

Explain how the router maps a bare class to Class@__invoke, when registration throws, and how only/except filters see the method name __invoke.

for a senior

Argue when one-class-per-use-case helps a codebase and when it fragments it, and keep invokable controllers thin by delegating to action or service classes.

for a principal

Set a team convention for controller granularity that keeps routes discoverable, and decide where the line between controller and application layer sits.

## The idea A normal Laravel controller groups several **actions** (public methods) about one subject. A **single-action controller**, also called an **invokable controller**, has exactly one job and exposes it through PHP's magic `__invoke` method, the method PHP calls when an object is used like a function. The class name then *is* the action name: `ReturnRental`, `ExtendRental`, `DownloadInvoice`. ## Generating one ```bash php artisan make:controller ReturnRental --invokable ``` `--invokable` (short `-i`) selects the invokable stub: a class that extends the base `Controller` and contains a single `public function __invoke(Request $request)`. The same choice appears as **Invokable** in the interactive menu `make:controller` shows when it is run without a name. ## Routing to it You give the router the class and nothing else: ```php Route::post('/rentals/{rental}/return', ReturnRental::class); ``` What happens under the hood: 1. `RouteAction::parse` receives a string with no `@` in it. 2. It checks `method_exists($class, '__invoke')`. 3. If the method exists, the action is stored as `ReturnRental@__invoke` and behaves like any other controller action: the container builds the class, route parameters and type-hints are resolved, the return value becomes the response. 4. If it does not, registration throws `UnexpectedValueException` with the message *Invalid route action: [App\Http\Controllers\ReturnRental]*, so the mistake shows up as soon as the routes file loads rather than on the first request. ## When it is the right shape | Situation | Invokable controller? | |---|---| | A verb outside create/read/update/delete, such as "return this car" | Yes, a natural fit | | One endpoint needs heavy dependencies the rest of the controller does not | Yes, keeps constructor injection focused | | A form's GET and POST handlers about the same subject | Usually no, a two-action controller reads better | | A standard list/show/store/update/destroy set | No, that is what resource controllers are for | Good reasons to choose one: - **Naming**: the route file reads like a list of use cases. - **Focused dependencies**: the constructor lists only what this action needs. - **Smaller diffs and files**: a change to one endpoint touches one small class. - **Testing**: a feature test targets one route whose code lives in one place. Reasons for caution: - A folder of hundreds of tiny classes can be harder to navigate than a handful of cohesive controllers; teams usually pick a convention and stick to it. - An invokable controller that grows helper logic is still a fat controller; the business steps belong in a separate action or service class the controller calls. ## Three ways to handle one endpoint | Shape | Where the code lives | Gets constructor injection | Typical use | |---|---|---|---| | Closure route | Inline in `routes/web.php` | No, only parameter injection | Tiny prototypes, a static page | | Action on a multi-action controller | A public method beside related actions | Shared with the other actions | CRUD-style groups | | Single-action controller | Its own class, `__invoke` | Yes, just for this endpoint | Verbs and complex one-off endpoints | Closure routes can be route-cached in current Laravel, so caching is no longer the reason to avoid them; the reasons are testability, reuse and keeping handler code out of the routes file. Between the two controller shapes the trade-off is cohesion versus focus: a multi-action controller keeps related handlers together, while an invokable one isolates a single use case and its dependencies. ## Details worth knowing - An invokable controller may still have other methods. Private helpers are fine; public methods could even be routed with the array syntax, but doing so defeats the point of the shape. - Controller middleware applies to it like any other controller. The method name used by `only` and `except` filters is `__invoke`, so a filter such as `only: ['store']` would exclude the action entirely. - Invokable controllers are routed individually; they are not registered by resource-route helpers, which expect named actions like `index` and `store`. In short, `__invoke` turns a controller into a one-purpose endpoint class, and the router's only special handling is to accept the bare class name and verify the method exists when routes are registered.

  • What happens if you route to a class that has no __invoke method?
    `RouteAction::parse` checks `method_exists($class, '__invoke')` while the route is registered and throws `UnexpectedValueException` with *Invalid route action: [Class]*. The error appears when the routes file loads, before any request is served.
  • Why not just use a closure route for a one-off endpoint?
    A closure in the routes file cannot be reused, is awkward to unit-test in isolation, and mixes handler code into routing. An invokable controller gets constructor injection, a meaningful class name, and a home for private helpers, at the cost of one extra file.

saying these in an interview costs you the question

  • Invokable controllers need the full [Class::class, '__invoke'] array to be routed.
  • A class without __invoke fails only when the route is first requested.
  • An invokable controller cannot have any other methods.
  • make:class --invokable generates an invokable controller in app/Http/Controllers.
  • Single-action controllers skip controller middleware.