# Single action controllers

Een controller met één methode, \_\_invoke, voor accepteren, dupliceren, exporteren en alles wat niet in de zeven CRUD-methodes past.

Multimedia & Creatieve Technologie · 3.1 The Agency · Bite · versie 1.0.0 · 2026-10-02

Tags: laravel, php, architecture

Slides: <https://slides.mctantwerp.be/nl/the-agency/single-action-controllers>

<!-- slide 2 -->

## Na deze les kan je

- Een single action controller schrijven en aan een route hangen
- Uitleggen wat `__invoke` doet, in PHP en in Laravel
- Middleware en autorisatie op één actie zetten
- Kiezen tussen een extra methode, een single action controller en een CRUD-controller voor een nieuw ding
- Een test schrijven die blijft werken als je de controller herschrijft

> **Toelichting:** Single action controllers zijn klein, maar je komt ze overal tegen: in de docs van Laravel, in packages van Spatie, in elk project dat je overneemt. De laatste slide is een opdracht op je eigen project. Ze is kort, en het loont om ze meteen te doen.

<!-- slide 3 -->

## Een controller die blijft groeien

```php
class QuotesController
{
    public function index() { /* ... */ }
    public function show(Quote $quote) { /* ... */ }
    public function store(StoreQuoteRequest $request) { /* ... */ }
    public function update(UpdateQuoteRequest $request, Quote $quote) { /* ... */ }
    public function destroy(Quote $quote) { /* ... */ }
    public function accept(Quote $quote) { /* ... */ }
    public function duplicate(Quote $quote) { /* ... */ }
    public function downloadPdf(Quote $quote) { /* ... */ }
    public function sendReminder(Quote $quote) { /* ... */ }
}
```

De eerste vijf zijn CRUD, de laatste vier werkwoorden die de klant er één voor één bij vroeg. Ze hebben elk eigen dependencies en tests, en ze delen niets met `index()` behalve het woord offerte.

> **Toelichting:** Zo een controller ontstaat niet in één keer. Elke sprint komt er een knop bij, en een methode erbij lijkt dan de kleinste stap. Na een jaar vraagt de constructor zes dependencies die elke methode meekrijgt, ook index(). Wil je zien hoe het met je eigen project zit: php artisan route:list --path=quotes toont alle routes onder /quotes, met de controller erbij.

<!-- slide 4 -->

## Eén klasse, één methode

*app/Http/Controllers/Quotes/AcceptQuoteController.php*

```php
namespace App\Http\Controllers\Quotes;

class AcceptQuoteController
{
    public function __invoke(Quote $quote, AcceptQuoteAction $accept): RedirectResponse
    {
        $accept->execute($quote);

        return to_route('quotes.show', $quote)->with('status', 'Offerte aanvaard.');
    }
}
```

Eén publieke methode: `__invoke`. De naam van de klasse zegt wat hij doet, een werkwoord en het ding waarop het werkt. Accepteren krijgt zo een eigen bestand, met alleen de dependencies die accepteren nodig heeft.

> **Toelichting:** extends Controller mag weg: sinds Laravel 11 is de basis-Controller een lege abstracte klasse. Zet je zulke controllers in een submap zoals Quotes, dan blijft app/Http/Controllers leesbaar als er twintig bijkomen. Het werk zelf zit hier in een action, zoals in de les over architectuur. Een korte single action controller met de logica er rechtstreeks in mag ook, zolang hij één ding doet.

<!-- slide 5 -->

## \_\_invoke in PHP

```php
class Greeter
{
    public function __invoke(string $name): string
    {
        return "Dag {$name}";
    }
}

$greet = new Greeter();

echo $greet('Sam');                    // Dag Sam
echo is_callable($greet) ? 'ja' : 'nee'; // ja
```

`__invoke` komt uit PHP zelf, niet uit Laravel. Een object met die methode roep je aan alsof het een functie is. Laravel maakt je controller aan via de service container en roept hem daarna gewoon aan. Meer in de [PHP-docs](https://www.php.net/manual/en/language.oop5.magic.php#object.invoke).

> **Toelichting:** Magische methodes herken je aan de twee underscores: __construct, __get, __toString. PHP roept ze zelf aan op een vast moment, jij roept ze nooit rechtstreeks aan. Je ziet invokable klassen ook buiten controllers: sommige teams maken hun actions invokable, en Schedule::call() in de scheduler neemt een invokable object aan.

<!-- slide 6 -->

## De route

*routes/web.php*

```php
use App\Http\Controllers\Quotes\AcceptQuoteController;

Route::post('quotes/{quote}/accept', AcceptQuoteController::class)
    ->name('quotes.accept');

Route::get('quotes/{quote}', [QuotesController::class, 'show'])
    ->name('quotes.show');
```

Je geeft de klasse zelf, zonder array en zonder methodenaam, en Laravel roept `__invoke` aan. Ter vergelijking eronder een gewone methode. Route model binding werkt zoals altijd: `{quote}` wordt de `Quote $quote` in `__invoke`. Zie [single action controllers](https://laravel.com/docs/13.x/controllers#single-action-controllers) in de docs.

> **Toelichting:** Geef de route altijd een naam. Mails, redirects en tests gebruiken dan route('quotes.accept', $quote), en de url kan later veranderen zonder dat er iets breekt. Het is een POST, omdat accepteren data verandert. Komt de klant via een link in een mail, dan is dat een GET: laat die link naar een pagina met een knop gaan, en beveilig hem met een signed url (zie de les over security).

<!-- slide 7 -->

## Aanmaken met Artisan

```console
$ php artisan make:controller Quotes/AcceptQuoteController --invokable
  INFO  Controller [app/Http/Controllers/Quotes/AcceptQuoteController.php] created successfully.
```

```php
class AcceptQuoteController extends Controller
{
    /**
     * Handle the incoming request.
     */
    public function __invoke(Request $request)
    {
        //
    }
}
```

Met `--invokable` staat `__invoke` er meteen in. De slash in de naam maakt de submap en de namespace. Vervang `Request $request` door wat je echt nodig hebt: het model uit de route, een Form Request, een action.

> **Toelichting:** Zonder --invokable krijg je een lege klasse, met --resource de zeven CRUD-methodes. Stoort de docblock of de extends Controller je: php artisan stub:publish zet alle stubs in een map stubs/ in je project. Pas daar controller.invokable.stub aan, en make:controller gebruikt vanaf dan jouw versie, voor het hele team.

<!-- slide 8 -->

## Dependencies

*app/Http/Controllers/Quotes/DownloadQuotePdfController.php*

```php
class DownloadQuotePdfController
{
    public function __construct(
        private PdfRenderer $pdf,
    ) {}

    public function __invoke(Quote $quote): Response
    {
        return $this->pdf->download('quotes.pdf', ['quote' => $quote], "offerte-{$quote->number}.pdf");
    }
}
```

Met tien methodes draait de constructor ook voor `index()`, die geen PDF nodig heeft. Met één methode maakt het niet uit: constructor of `__invoke`, kies er één en hou het overal zo.

> **Toelichting:** PdfRenderer is de interface uit de les over architectuur, de implementatie is verzonnen. Een route-parameter zoals {quote} kan alleen in __invoke, nooit in de constructor: Laravel maakt de controller voor het de parameters aan een methode koppelt. Zie je in een ouder project $this->middleware() in een constructor, dan is dat de vorm van voor Laravel 11.

<!-- slide 9 -->

## Middleware en autorisatie

```php
Route::post('quotes/{quote}/accept', AcceptQuoteController::class)
    ->middleware(['auth', 'can:accept,quote']);
```

*of in de controller zelf*

```php
class AcceptQuoteController implements HasMiddleware
{
    public static function middleware(): array
    {
        return ['auth', 'can:accept,quote'];
    }
```

Op de route zie je in `routes/web.php` meteen wie wat mag, met `HasMiddleware` staat het bij de controller. Er is maar één methode, dus `only` en `except` heb je nooit nodig.

> **Toelichting:** can:accept,quote roept de methode accept() van je QuotePolicy aan, met de Quote uit de route. Een Form Request met authorize() werkt ook, en is handig als er toch al validatie is. Kies per project één plek: autorisatie op drie plekken is autorisatie die je ergens vergeet. Meer in de les over autorisatie.

<!-- slide 10 -->

## Wanneer wel, wanneer niet

### Single action controller

- Een werkwoord dat geen CRUD is: accepteren, dupliceren, herinneren
- Een download, zoals de PDF van een offerte
- Een pagina met één taak: de homepage, een dashboard
- Een webhook van Mollie of Stripe

### Resource controller

- De gewone levenscyclus van een ding: aanmaken, tonen, aanpassen, verwijderen
- `Route::resource()` maakt alle routes in één regel
- Geen zeven single action controllers voor CRUD: zeven bestanden voor wat één klasse netjes doet

> **Toelichting:** Sommige teams maken van elke route een single action controller, ook voor CRUD, in de stijl van action, domain, responder (ADR). Dat werkt, maar je krijgt veel bestanden en Route::resource() valt weg. In de meeste Laravel-projecten die je overneemt, vind je de afspraak van Spatie: CRUD in een resource controller, de rest apart.

<!-- slide 11 -->

## Of toch CRUD

*routes/web.php*

```php
// een werkwoord: single action controller
Route::post('quotes/{quote}/accept', AcceptQuoteController::class);

// een ding: een acceptatie aanmaken en weer intrekken
Route::post('quotes/{quote}/acceptance', [QuoteAcceptanceController::class, 'store']);
Route::delete('quotes/{quote}/acceptance', [QuoteAcceptanceController::class, 'destroy']);
```

Adam Wathan noemt het tweede [Cruddy by Design](https://github.com/adamwathan/laracon2017): een offerte accepteren is een acceptatie aanmaken, dus `store()`. De [guidelines van Spatie](https://spatie.be/guidelines/laravel) zeggen hetzelfde: blijf bij de CRUD-woorden en maak een nieuwe controller voor de rest. Klinkt het ding geforceerd (een herinneringsverzending?), neem dan de single action controller.

> **Toelichting:** Beide vormen zijn goed, en je ziet ze allebei in echte projecten. De CRUD-vorm geeft een plek aan wat later bijkomt: een acceptatie tonen, of ze intrekken met destroy(). De single action controller leest vlotter in routes/web.php. Wat je ook kiest: geen accept() als achtste methode in QuotesController.

<!-- slide 12 -->

## Testen

*tests/Feature/AcceptQuoteTest.php*

```php
it('accepts a quote', function () {
    $quote = Quote::factory()->create();

    $this->actingAs($quote->client)
        ->post(route('quotes.accept', $quote))
        ->assertRedirect(route('quotes.show', $quote));

    expect($quote->fresh()->accepted_at)->not->toBeNull();
});
```

De test gaat via de naam van de route, niet via de klasse. Of accepteren een methode is, een single action controller of een `QuoteAcceptanceController`: deze test verandert niet als je de controller herschrijft. Daarom schrijf je hem best voor je begint te verhuizen.

> **Toelichting:** Schrijf er een tweede test bij voor iemand die het niet mag, met assertForbidden(). Die vangt de fout waarbij iemand de middleware van de route haalt. Test je de action apart, dan blijft de controllertest kort: één keer het gewone pad en één keer een 403 is genoeg.

<!-- slide 13 -->

## De route wijst naar de methode accept, maar de controller heeft alleen \_\_invoke. Wat gebeurt er bij een request?

- A. Laravel valt terug op \_\_invoke en alles werkt
- B. Een error: de methode accept bestaat niet
- C. Een 404, omdat de route geen methode vindt
- D. php artisan route:list weigert te starten

**Antwoord:** B. Een error: de methode accept bestaat niet

Met een array zoekt Laravel exact de methode die je noemt, en die bestaat niet. Naar \_\_invoke gaat het alleen als je de klasse zonder methode meegeeft. De 404 is verleidelijk, maar die krijg je als de url of de offerte niet gevonden wordt, niet bij een fout in je controller.

> **Toelichting:** De omgekeerde fout zie je ook: een gewone controller met alleen index(), en een route met QuotesController::class zonder methode. Dan geeft Laravel al bij het laden van je routes de fout Invalid route action, ook bij php artisan route:list. Die fout zie je dus meteen, de fout uit de vraag pas als iemand op de knop drukt.

<!-- slide 14 -->

## Opdracht: Haal een werkwoord uit je controller

Klaar als de oude methode weg is, php artisan route:list je nieuwe controller toont zonder @methode erachter, en beide tests groen zijn. Vast? Slide 6 voor de route, slide 12 voor de test.

1. Zoek in je Agency-project een controllermethode die geen CRUD is
2. Schrijf een feature test via de naam van de route, en een tweede voor iemand die het niet mag
3. Maak een single action controller met make:controller --invokable en verhuis de code
4. Hang de route aan de nieuwe klasse, met dezelfde naam, en draai de tests opnieuw

> **Toelichting:** Begin met de test, op de oude methode. Is die groen, verhuis dan de code en draai hem opnieuw: blijft hij groen zonder dat je de test aanpast, dan weet je dat de route niet veranderde. Vind je geen methode die geen CRUD is, kijk dan in store() en update(): een if op een status is vaak een eigen actie die zich verstopt.

## Begrippen

- **Single action controller**: Een controller met één publieke methode, \_\_invoke. Voor een actie die geen CRUD is, zoals een offerte accepteren. Je maakt hem met make:controller --invokable en geeft de klasse zelf aan de route, zonder methodenaam. (slide 4)
- **\_\_invoke**: De magische methode van PHP die draait als je een object aanroept alsof het een functie is: $greet('Sam'). Laravel roept hem aan als je een controller zonder methode aan een route geeft. (slide 5)
- **HasMiddleware**: De interface waarmee een controller zelf zegt welke middleware hij nodig heeft, met een statische methode middleware(). Vervangt $this->middleware() in de constructor van voor Laravel 11. (slide 9)
- **Cruddy by Design**: Het idee van Adam Wathan om een werkwoord als een ding te zien: een offerte accepteren wordt een acceptatie aanmaken, met store() op een QuoteAcceptanceController. Zo blijft elke controller bij de zeven CRUD-methodes. (slide 11)

## Links en bronnen

### In de presentatie

- [Magic methods: \_\_invoke](https://www.php.net/manual/en/language.oop5.magic.php#object.invoke) (slide 5): Een object aanroepen als een functie
- [Single action controllers](https://laravel.com/docs/13.x/controllers#single-action-controllers) (slide 6): make:controller --invokable en de route zonder methode
- [Cruddy by Design](https://github.com/adamwathan/laracon2017) (slide 11): Adam Wathan op Laracon US 2017: een werkwoord als resource
- [Spatie guidelines: Laravel](https://spatie.be/guidelines/laravel) (slide 11): CRUD-methodes in controllers, een aparte controller voor de rest

### Bronnen

- [Controller middleware](https://laravel.com/docs/13.x/controllers#controller-middleware): HasMiddleware en de statische middleware()
- [Stub customization](https://laravel.com/docs/13.x/artisan#stub-customization): stub:publish en controller.invokable.stub
- [Scheduling invokable objects](https://laravel.com/docs/13.x/scheduling#scheduling-invokable-objects): Schedule::call() met een invokable klasse
- [HTTP tests](https://laravel.com/docs/13.x/http-tests): actingAs, assertRedirect en assertForbidden
