# Application architecture

Van een fat controller naar een Form Request, een action en een controller van vijf regels. Met services, dependency injection en de vraag of je een repository nodig hebt.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.1.0 · 2026-10-02

Tags: laravel, php, architecture, design-patterns

Slides: <https://slides.mctantwerp.be/nl/the-agency/06-architectuur>

<!-- slide 2 -->

## Agenda

1. Fat controllers
2. Form Requests
3. Actions
4. Dunne controllers
5. Dependency injection
6. Repositories
7. Projectstructuur

> **Toelichting:** De hoofdstukken bouwen op elkaar: eerst het probleem, dan elke plek waar code naartoe kan. Dependency injection en repositories zijn korter, omdat de les over de service container het volledige verhaal heeft. Heb je weinig tijd, doe dan eerst hoofdstuk 1 tot 4: die gebruik je elke week.

<!-- slide 3 -->

## Na deze les kan je

- In een bestaande controller aanwijzen welke regels validatie, use case, regel over een model of HTTP zijn
- Validatie en autorisatie naar een Form Request verhuizen
- Een action schrijven met getypeerde input, die ook vanuit een job of een test werkt
- Kiezen tussen een resource controller en een single action controller
- Uitleggen waarom een repository bovenop Eloquent meestal niets oplevert, en wanneer wel

> **Toelichting:** Na deze les lees je je eigen controllers anders, en dat is de bedoeling. De opdracht op het einde vraagt om één controller uit je project om te bouwen. Neem je tijd voor de stapper op slide 11, daar komt alles samen.

<!-- slide 4 -->

## 1. Fat controllers

> **Toelichting:** In een Agency-project groeit een controller vanzelf: elke nieuwe wens van de klant komt erbij. In week twee is dat geen probleem, in week tien wel. Dit hoofdstuk toont waarom, met een controller die je waarschijnlijk herkent.

<!-- slide 5 -->

### Offertes voor Janssens

Het voorbeeld in deze les, en ook in de les over domain-driven design. Schrijnwerkerij Janssens maakt keukens op maat en maakt offertes nu nog in Word. Jouw team bouwt de tool waarmee ze offertes opmaken en versturen.

- **Opmaken**: Lijnen met een omschrijving en een prijs. Een renovatie krijgt 6% btw, een nieuwbouw 21%.
- **Versturen**: De klant krijgt een mail met de offerte. De status gaat naar verstuurd.
- **Opvolgen**: De klant keurt goed of weigert. Bij goedkeuring volgt een voorschotfactuur.

> **Toelichting:** Janssens is verzonnen, maar het soort project is typisch: een KMO die een proces uit Word en Excel naar een webapp wil halen. De regels van zo een klant, zoals btw, voorschotten en statussen, raken als eerste verloren in een fat controller. Hetzelfde voorbeeld komt terug in de les over domain-driven design.

<!-- slide 6 -->

### De eerste versie

*app/Http/Controllers/QuotesController.php*

```php
public function store(Request $request, Project $project)
{
    if ($request->user()->team_id !== $project->team_id) {
        abort(403);
    }

    $data = $request->validate([
        'email' => ['required', 'email'],
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.price' => ['required', 'integer'],
    ]);
```

Zo ziet een eerste versie er meestal uit, en ze werkt. Eerst een check of je bij het team van het project hoort, dan de validatie van het formulier. Prijzen staan in cent, als integer.

> **Toelichting:** Waarom in cent? Met floats rekent PHP niet exact: 0.1 + 0.2 is 0.30000000000000004. Met integers in cent klopt elk bedrag, en pas bij het tonen deel je door honderd.

<!-- slide 7 -->

### Opslaan, rekenen, mailen

*QuotesController@store, vervolg*

```php
    $quote = $project->quotes()->create(['email' => $data['email']]);
    $quote->lines()->createMany($data['lines']);

    $rate = $project->is_renovation ? 1.06 : 1.21;
    $total = $quote->lines->sum('price') * $rate;
    $quote->update(['total' => (int) round($total)]);

    Mail::to($quote->email)->send(new QuoteMail($quote));

    return redirect()->route('quotes.show', $quote);
}
```

Daarna opslaan, het totaal met de juiste btw berekenen, een mail sturen en terug naar de offerte. Vier soorten werk in één methode, en geen transaction: faalt de mail, dan staat er een halve offerte in je database.

> **Toelichting:** Zonder transaction kan het ook misgaan tussen create() en createMany(): dan heb je een offerte zonder lijnen. DB::transaction() rolt alles terug als er een exception komt. Een mail kan je niet terugrollen, daarom staat die in de action na de transaction.

<!-- slide 8 -->

### Wat er mis mee is

- Stel: de verkopers krijgen een app die via een API ook offertes maakt. Dan kopieer je deze methode, en na een paar maanden rekenen de twee kopieën elk anders.
- Testen kan alleen via een HTTP-request met een ingelogde gebruiker erbij. De btw-regel apart testen gaat niet.
- De btw-regel staat in een controller. Een tweede plek die een totaal nodig heeft, zoals een PDF of een export, schrijft hem opnieuw.
- In een team van vier past iedereen dezelfde methode aan, dus elke merge wordt een conflict.

> **Toelichting:** Geen van deze problemen zie je op de dag dat je de code schrijft. Ze komen pas als het project groeit, of als iemand anders de code moet aanpassen. Zoek in je eigen project de langste controllermethode en tel hoeveel van deze vier je herkent.

<!-- slide 9 -->

**Een controller vertaalt HTTP.**

Request binnen, je code aanroepen, response terug. Alles daartussen hoort ergens anders.

> **Toelichting:** Op deze regel steunt de rest van de les. Een controller kent HTTP: requests, redirects, views en JSON. Staat er in een controller iets wat je ook vanuit een artisan command of een job zou willen doen, dan hoort het ergens anders.

<!-- slide 10 -->

### Wie doet wat

| Klasse | Verantwoordelijk voor | In het voorbeeld |
| --- | --- | --- |
| Form Request | Wat er binnenkomt en wie het mag | `StoreQuoteRequest` |
| Action | Eén use case, van begin tot einde | `SendQuoteAction` |
| Model | Regels over zijn eigen data | `Quote::recalculateTotal()` |
| Service | Een externe partij of een techniek inpakken | `PdfRenderer` |
| Controller | HTTP in, HTTP uit | `QuotesController` |

Elke rij krijgt in deze les een eigen hoofdstuk, behalve het model: daar komt de les over domain-driven design op terug.

> **Toelichting:** Van de namen in de kolom Klasse zijn alleen Form Request en Controller echte Laravel-begrippen. Action en Service zijn afspraken die veel teams gebruiken, met kleine verschillen per team. Belangrijk is dat je team één afspraak kiest en ze overal volgt.

<!-- slide 11 -->

### Waar elke regel naartoe gaat

Stap door de controller: elke stap opent één groep regels, en rechts staat waar ze naartoe gaan en waarom.

QuotesController.php, de dikke versie:

```php
public function store(Request $request, Project $project)
{
    if ($request->user()->team_id !== $project->team_id) {
        abort(403);
    }
    $data = $request->validate([
        'email' => ['required', 'email'],
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.price' => ['required', 'integer'],
    ]);

    $quote = $project->quotes()->create([
        'email' => $data['email'],
    ]);
    $quote->lines()->createMany($data['lines']);

    $rate = $project->is_renovation ? 1.06 : 1.21;
    $total = $quote->lines->sum('price') * $rate;
    $quote->update(['total' => (int) round($total)]);

    Mail::to($quote->email)->send(new QuoteMail($quote));
    return redirect()->route('quotes.show', $quote);
}
```

Waar de regels naartoe gaan:

- StoreQuoteRequest (8 regels): Wie het mag en wat er binnenkomt. Laravel checkt dat voor je controller begint.
- SendQuoteAction (5 regels): De use case zelf: opslaan en de mail. Zo kan een job of een API hetzelfde doen.
- Quote (3 regels): Een regel over de offerte zelf. Op het model rekent elke plek met dezelfde btw.

Wat overblijft in QuotesController.php:

```php
public function store(
    StoreQuoteRequest $request,
    Project $project,
    SendQuoteAction $sendQuote,
): RedirectResponse {



    $quote = $sendQuote->execute($project, $request->toData());

    return redirect()->route('quotes.show', $quote);
}
```

> **Toelichting:** Kijk bij elke stap naar het aantal regels bovenaan: van 20 naar 8. Het werk verdwijnt niet, het verhuist naar klassen met één taak. Die klassen kan je apart testen, en een tweede controller of een API kan ze hergebruiken.

<!-- slide 12 -->

## 2. Form Requests

> **Toelichting:** Validatie en autorisatie kunnen uit bijna elke controller, en het is de makkelijkste stap. Een Form Request is bovendien gewoon Laravel: elke Laravel-developer die je project opent, herkent hem meteen.

<!-- slide 13 -->

### Een Form Request

```console
$ php artisan make:request StoreQuoteRequest
INFO  Request [app/Http/Requests/StoreQuoteRequest.php] created successfully.
```

- Een Form Request is een klasse per request, met de validatie en de autorisatie samen op één plek.
- Laravel draait hem voor je controllermethode begint. Komt je code in de controller, dan is de input al goed.
- Faalt de validatie, dan gaat de gebruiker terug naar het formulier met de fouten, of krijgt een API een 422 met JSON.
- Alles over Form Requests staat in de [Laravel docs](https://laravel.com/docs/13.x/validation#form-request-validation).

> **Toelichting:** De stub van make:request zet authorize() op false. Vergeet je dat aan te passen, dan geeft je nieuwe route meteen een 403 en zoek je de fout in je policy. Staat de autorisatie al in een middleware of een policy op de route, dan mag authorize() gewoon true teruggeven.

<!-- slide 14 -->

### Wie het mag

*app/Http/Requests/StoreQuoteRequest.php*

```php
public function authorize(): bool
{
    return $this->user()->can('update', $this->route('project'));
}
```

`authorize()` vervangt de `if` met `abort(403)` uit de controller. `$this->route('project')` is het Project uit de url, en `can()` vraagt aan de policy of deze gebruiker het mag aanpassen.

Geeft ze false terug, dan krijgt de gebruiker een 403 en draait je controller niet. Policies zie je in de [les over autorisatie](/nl/the-agency/11-autorisatie).

> **Toelichting:** In een controller zelf gebruik je Gate::authorize('update', $project). De methode $this->authorize() uit oudere tutorials bestaat niet meer in de lege basis-Controller van Laravel 11 en later, tenzij je de trait AuthorizesRequests zelf toevoegt.

<!-- slide 15 -->

### Wat er binnenkomt

*app/Http/Requests/StoreQuoteRequest.php*

```php
public function rules(): array
{
    return [
        'email' => ['required', 'email'],
        'lines' => ['required', 'array', 'min:1'],
        'lines.*.description' => ['required', 'string', 'max:255'],
        'lines.*.price' => ['required', 'integer', 'min:0'],
    ];
}
```

Dezelfde regels als in de controller, plus een omschrijving per lijn. `lines.*` geldt voor elke lijn in de array. Schrijf de regels altijd als array, ook als er maar één is: dan kan je er later een Rule-object bij zetten.

> **Toelichting:** Validatie van arrays is een klassieke valkuil: 'lines' => 'array' checkt alleen dat het een array is, niet wat erin zit. De regels met lines.* gelden voor elk element. Stuur een lijn zonder prijs en je krijgt een fout op lines.0.price, zo zie je dat het werkt.

<!-- slide 16 -->

### In de controller

```php
public function store(StoreQuoteRequest $request, Project $project): RedirectResponse
{
    $data = $request->validated();

    $email = $request->safe()->only(['email']);
```

- Type de Form Request in plaats van `Request`. Dat is genoeg: Laravel maakt hem aan en valideert.
- `validated()` geeft alleen wat in `rules()` staat. Stuurt iemand `is_admin=1` mee, dan valt dat eruit.
- `safe()->only()` neemt een deel. Geef nooit `$request->all()` door aan `create()`.

> **Toelichting:** validated() geeft een array terug, safe() een object met only(), except() en merge(). $fillable op je model beschermt ook tegen mass assignment, maar validated() is de eerste verdediging: wat niet gevalideerd is, komt er niet door.

<!-- slide 17 -->

### Wat erin hoort

#### Wel in een Form Request

- `rules()` en `authorize()`, de reden dat hij bestaat.
- `prepareForValidation()` om input op te kuisen, zoals een btw-nummer zonder punten.
- `messages()` voor foutmeldingen in de taal van de klant.
- Een methode die de input omzet naar een DTO, verderop in deze les.

#### Niet

- Iets opslaan in de database. Dan draait je code al voor de controller, en is een test een raadsel.
- Mails sturen of prijzen berekenen. Dat is werk van de use case.
- Queries die niets met validatie of autorisatie te maken hebben.

> **Toelichting:** prepareForValidation() draait voor de regels, passedValidation() erna. Die tweede is handig om input na de validatie nog om te zetten. Wordt een Form Request langer dan de controller die je ermee wou opkuisen, dan staat er waarschijnlijk werk in dat naar een action hoort.

<!-- slide 18 -->

### authorize() in StoreQuoteRequest geeft false terug. Wat gebeurt er?

- A. De controller krijgt een lege request
- B. Laravel stuurt een 403, de controller draait niet
- C. Een redirect terug met validatiefouten
- D. Een 422 met de fouten in JSON

**Antwoord:** B. Laravel stuurt een 403, de controller draait niet

Laravel checkt autorisatie voor validatie. Mag je het niet, dan krijg je een 403 en komen je rules er niet eens aan te pas. De redirect met fouten en de 422 lijken logisch, maar die horen bij een mislukte validatie: dat is een andere situatie, waarin je het wel mag maar de input niet klopt.

> **Toelichting:** Kijk in je eigen tests of je beide gevallen hebt: een gebruiker die het niet mag (403) en een gebruiker die het mag maar foute input stuurt (422 of een redirect met fouten). Loopt er één mis, dan is dat een andere bug dan de andere, en dat wil je apart zien falen.

<!-- slide 19 -->

## 3. Actions

> **Toelichting:** Actions zijn het hart van deze les. Ze zijn geen Laravel-feature maar een afspraak, dus zie je ze in elk team een beetje anders. Wat overal hetzelfde is: één use case per klasse, en geen HTTP erin.

<!-- slide 20 -->

### Een action

*app/Actions/Quotes/SendQuoteAction.php*

```php
class SendQuoteAction
{
    public function execute(Project $project, QuoteData $data): Quote
    {
        // ...
    }
}
```

Een action is een gewone PHP-klasse met één use case: een offerte versturen. De naam is wat de klant zou zeggen, met Action erachter. Ze krijgt alles wat ze nodig heeft als getypeerde parameters, en geeft het resultaat terug.

Laravel heeft er geen apart concept voor, dus ook geen `make:action`. Het is een afspraak die veel agencies volgen, zoals Spatie in [Laravel Beyond CRUD](https://laravel-beyond-crud.com/).

> **Toelichting:** Sommige teams maken een action invokable met __invoke. Dan roep je ze aan als ($this->sendQuote)($project, $data). Werkt ook, maar execute() leest vlotter.

<!-- slide 21 -->

### Wat de action doet

```php
$quote = DB::transaction(function () use ($project, $data) {
    $quote = $project->quotes()->create(['email' => $data->email]);
    $quote->lines()->createMany($data->lines);

    return $quote->recalculateTotal();
});

Mail::to($quote->email)->queue(new QuoteMail($quote));

return $quote;
```

- In een transaction: faalt er iets, dan staat er geen halve offerte in de database.
- `queue()` in plaats van `send()`: de mail vertrekt via de queue, de gebruiker wacht er niet op.
- De action geeft de offerte terug. Wat de gebruiker daarna ziet, beslist de controller.

> **Toelichting:** DB::transaction() geeft terug wat de closure teruggeeft, daarom kan $quote meteen uit de transaction komen. Mail::queue() heeft een queue worker nodig. Lokaal met QUEUE_CONNECTION=sync vertrekt de mail meteen, zonder worker, en dat is handig om te testen.

<!-- slide 22 -->

### Afspraken voor een action

- Eén klasse, één use case. Een werkwoord en een zelfstandig naamwoord: SendQuoteAction.
- Eén publieke methode, execute of handle, en dat in het hele project. Spatie kiest execute, Laravel zelf gebruikt handle in jobs.
- Getypeerde input: modellen, DTOs en scalars. Nooit de Request.
- Geeft het resultaat terug. Geen redirect, geen view, geen flash message: dat is HTTP.

```console
$ php artisan make:class Actions/Quotes/SendQuoteAction
```

`make:class` maakt een lege klasse, sinds [Laravel 11](https://laravel.com/docs/11.x/releases).

> **Toelichting:** Execute of handle maakt technisch niets uit. Wat telt is dat je in een team van vier nooit moet raden hoe de methode heet. Zet de afspraak in de README, en laat een architectuurtest ze controleren (slide 52).

<!-- slide 23 -->

### Geen Request in een action

#### Zo niet

```php
public function execute(Request $request): Quote
{
    $email = $request->input('email');
}
```

Deze action werkt alleen als er een HTTP-request is. Een artisan command of een import uit Excel heeft er geen.

#### Zo wel

```php
public function execute(
    Project $project,
    QuoteData $data,
): Quote
```

Deze kan je aanroepen vanuit een controller, een job, een command en een test.

> **Toelichting:** Een test is het snelste bewijs: kan je de action aanroepen met een QuoteData en een Project uit een factory, zonder $this->post()? Dan zit er geen HTTP in. Lukt dat niet, dan hangt je action nog aan de request.

<!-- slide 24 -->

### Een DTO

- *app/Data/QuoteData.php*

  ```php
  final readonly class QuoteData
  {
      public function __construct(
          public string $email,
          public array $lines,
      ) {}
  }
  ```
- *StoreQuoteRequest*

  ```php
  public function toData(): QuoteData
  {
      return new QuoteData(
          email: $this->validated('email'),
          lines: $this->validated('lines'),
      );
  }
  ```

Een DTO brengt de gevalideerde data van de Form Request naar de action. Een array met strings als keys zegt niet wat erin zit, een DTO wel, en je editor vult het aan. [spatie/laravel-data](https://spatie.be/docs/laravel-data) doet hetzelfde met meer automatiek.

> **Toelichting:** readonly op de klasse maakt elke property readonly, dat kan sinds PHP 8.2. De array $lines is hier nog ongetypeerd; een volgende stap is een QuoteLineData per lijn. In de les over domain-driven design zie je hoe laravel-data dat automatisch doet.

<!-- slide 25 -->

### Regels op het model

*app/Models/Quote.php*

```php
public function recalculateTotal(): self
{
    $rate = $this->project->is_renovation ? 1.06 : 1.21;

    $this->update(['total' => (int) round($this->lines()->sum('price') * $rate)]);

    return $this;
}
```

- De btw hangt af van het project: 6% voor een renovatie, 21% voor de rest.
- Het totaal wordt berekend uit de lijnen in de database. Elke action, job of API krijgt zo hetzelfde bedrag.

> **Toelichting:** Het model wordt zo een beetje dikker, en dat is goed zolang het over zijn eigen data gaat. Een model dat mails stuurt of een API aanroept, gaat te ver. In de les over domain-driven design wordt de btw een enum en het bedrag een value object.

<!-- slide 26 -->

### Actions roepen actions aan

```php
class AcceptQuoteAction
{
    public function __construct(
        private CreateDepositInvoiceAction $createDepositInvoice,
    ) {}

    public function execute(Quote $quote): Invoice
    {
        $quote->update(['status' => QuoteStatus::Accepted]);

        return $this->createDepositInvoice->execute($quote);
    }
}
```

Grote use cases bouw je uit kleine. De boekhouding maakt een voorschotfactuur ook los.

> **Toelichting:** Constructor injection werkt hier omdat de container de action maakt. Maak je een action met new, dan moet je de dependencies zelf meegeven. Roept A een B aan die weer A aanroept, dan zijn je use cases niet goed afgebakend.

<!-- slide 27 -->

### Action of service class

#### Action

- Eén use case, zoals een offerte versturen.
- Een werkwoord als naam: SendQuoteAction.
- Klein, meestal onder de vijftig regels.
- Weet wat de klant wil dat er gebeurt.

#### Service class

- Pakt één ding in: een PDF-renderer, de API van een ERP.
- Een zelfstandig naamwoord als naam: PdfRenderer.
- Meerdere methodes rond dat ene ding.
- Stel: een QuoteService met create, send, accept, duplicate en export. Na een jaar is dat een klasse van 800 regels.

> **Toelichting:** Een service is niet slecht, een service zonder grens wel. Een PdfRenderer met render() en store() blijft klein, omdat hij over één techniek gaat. Een QuoteService groeit omdat alles over offertes erin past.

<!-- slide 28 -->

### Side effects

```php
Mail::to($quote->email)->queue(new QuoteMail($quote));
```

- Een mail, een Slack-bericht of een call naar een API: alles wat de use case buiten de database doet, zet je in de action, na het opslaan.
- Een mail die drie seconden duurt, hoort niet in de request. Met `queue()` stuurt een worker hem, zie de [les over queues](/nl/the-agency/13-queues).
- Moeten er na goedkeuring drie dingen gebeuren in drie delen van de app, dan wordt het een event met listeners. Dat zie je in de les over domain-driven design.

> **Toelichting:** Stel: de mailserver ligt even plat. Met send() krijgt de gebruiker een foutpagina en weet hij niet of de offerte bewaard is. Met queue() is de offerte bewaard, en probeert de worker de mail later opnieuw.

<!-- slide 29 -->

### Queue je in een transaction

```php
Mail::to($quote->email)->queue((new QuoteMail($quote))->afterCommit());
```

Zet je een mail op de queue binnen een `DB::transaction`, dan kan de worker hem oppikken voor de transaction klaar is. De worker zoekt de offerte en vindt ze nog niet.

`afterCommit()` wacht tot de transaction gecommit is. Meer in de docs over [queued mail](https://laravel.com/docs/13.x/mail#queueing-mail).

> **Toelichting:** Je kan dit ook per queue connection aanzetten, met after_commit op true in config/queue.php. Dan wacht elke job van die connection op de commit.

<!-- slide 30 -->

### Welke signature past het best bij een action?

- A. `execute(Request $request): RedirectResponse`
- B. `execute(Project $project, QuoteData $data): Quote`
- C. `handle(array $input): void`
- D. `__construct(Request $request)`

**Antwoord:** B. `execute(Project $project, QuoteData $data): Quote`

Getypeerde input zonder Request, en het resultaat terug. Zo draait dezelfde action vanuit een controller, een job, een command en een test. De eerste is verleidelijk omdat ze lijkt op een controller, maar daar zit net het probleem: zonder HTTP-request kan je ze niet gebruiken, en de redirect is werk van de controller.

> **Toelichting:** Een action die niets teruggeeft is soms prima, bijvoorbeeld een action die alleen een herinnering stuurt. Geef bij twijfel toch iets terug: de controller heeft het vaak nodig voor de redirect, en een test kan erop controleren.

<!-- slide 31 -->

## 4. Dunne controllers

> **Toelichting:** Met een Form Request en een action blijft er weinig over in de controller. Dit hoofdstuk toont wat er overblijft, en hoe je kiest tussen een resource controller en een controller met één methode.

<!-- slide 32 -->

### De controller, daarna

*app/Http/Controllers/QuotesController.php*

```php
public function store(
    StoreQuoteRequest $request,
    Project $project,
    SendQuoteAction $sendQuote,
): RedirectResponse {
    $quote = $sendQuote->execute($project, $request->toData());

    return redirect()->route('quotes.show', $quote);
}
```

- De parameters: Laravel valideert de Form Request, zoekt het Project en maakt de action aan.
- Eén regel werk: de action met de data uit de Form Request.
- De response. Alles in deze methode is nu HTTP.

> **Toelichting:** SendQuoteAction als parameter van store() is method injection. Je kan de action ook in de constructor van de controller vragen, maar dan maakt Laravel ze aan voor elke methode van die controller, ook voor index() die ze niet nodig heeft.

<!-- slide 33 -->

### Single action controllers

*app/Http/Controllers/AcceptQuoteController.php*

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

        return to_route('quotes.thanks', $quote);
    }
}
```

De klant keurt een offerte goed via een link in de mail. Accepteren is geen van de zeven CRUD-methodes, dus krijgt het een eigen controller met één methode: `__invoke`.

> **Toelichting:** Een link in een mail moet beveiligd zijn, anders kan iedereen met de url elke offerte goedkeuren. Daarvoor zijn signed urls, zie de les over security. De controller breidt hier niets uit: sinds Laravel 11 is de basis-Controller leeg, dus is extends Controller optioneel.

<!-- slide 34 -->

### De route

*routes/web.php*

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

Je geeft de klasse zelf aan de route, zonder methodenaam, en Laravel roept `__invoke` aan. Zie [single action controllers](https://laravel.com/docs/13.x/controllers#single-action-controllers) in de docs.

**[Single action controllers](https://slides.mctantwerp.be/nl/the-agency/single-action-controllers)**: Artisan, middleware op één actie, wanneer je beter een CRUD-controller maakt, en hoe je hem test.

> **Toelichting:** Met php artisan route:list zie je de route met AcceptQuoteController als actie, zonder @methode erachter. De bite gaat dieper dan deze les: wat __invoke in PHP doet, make:controller --invokable, middleware op één actie, de CRUD-vorm van Adam Wathan en hoe je zo een controller test. Reken op een kwartier.

<!-- slide 35 -->

### Resource of single action

#### Resource controller

- Zeven methodes: index, create, store, show, edit, update en destroy.
- `Route::resource()` maakt alle zeven routes in één regel.
- Voor de gewone levenscyclus van een ding: offertes aanmaken, tonen, aanpassen.

#### Single action controller

- Eén methode: `__invoke`.
- Voor een werkwoord dat geen CRUD is: accepteren, dupliceren, exporteren.
- De regel van [Spatie](https://spatie.be/guidelines/laravel): past het niet in de zeven, dan een nieuwe controller.

> **Toelichting:** Route::resource('quotes', QuotesController::class)->only(['index', 'show', 'store']) maakt alleen de routes die je nodig hebt. Een nested resource zoals projects.quotes geeft urls als /projects/{project}/quotes, met beide modellen in je methode.

<!-- slide 36 -->

### Waar hoort het

Kies voor elke eis van de klant waar de code komt, en lees daarna waarom. De bolletjes houden je score bij.

- Het e-mailadres van de klant moet geldig zijn: Form Request. Validatie van wat er via HTTP binnenkomt: rules() in de Form Request.
- Alleen iemand van het team van het project mag een offerte maken: Form Request. authorize() in de Form Request, met een policy erachter. Zie de les over autorisatie.
- Een renovatie krijgt 6% btw, een nieuwbouw 21%: Model. Een regel over de offerte zelf. In een action kan ook, maar dan rekent de volgende action het opnieuw uit. Op het model rekent iedereen met dezelfde btw.
- Na het versturen krijgt de klant een mail: Action. Een side effect van de use case. De action queuet de mail na het opslaan.
- Producten en prijzen komen uit het ERP van de klant: Service. Een externe partij. Pak ze in, zodat de rest van de app geen url of token van dat ERP kent.
- Na het opslaan ga je terug naar de offerte, met een melding: Controller. Een redirect en een flash message zijn HTTP. Dat is wat de controller nog doet.

> **Toelichting:** Voor sommige eisen zijn er verdedigbare alternatieven: de btw-regel in een action, of het ERP via een repository in plaats van een service. Het antwoord hier is de keuze die het vaakst loont. Kan je jouw andere keuze uitleggen, dan zit je goed.

<!-- slide 37 -->

### De klant wil een offerte kunnen dupliceren. Waar komt die code?

- A. `QuotesController@duplicate`
- B. `DuplicateQuoteController` en een action
- C. In `store()`, met `?duplicate=1`
- D. In `update()`, want de offerte bestaat al

**Antwoord:** B. `DuplicateQuoteController` en een action

Dupliceren is geen CRUD, dus een eigen controller, en het werk zelf in een action. Een methode duplicate() op QuotesController werkt ook, maar dan groeit die controller met elke nieuwe knop. En update() is verleidelijk omdat de offerte al bestaat, maar je past ze niet aan: je maakt een nieuwe.

> **Toelichting:** De Spatie-regel geeft nog een optie: een QuoteDuplicatesController met store(), omdat dupliceren eigenlijk een nieuwe offerte aanmaakt. Zo blijf je bij de CRUD-woorden. Beide oplossingen zijn goed, zolang het werk in een action zit.

<!-- slide 38 -->

## 5. Dependency injection

> **Toelichting:** Dependency injection gebeurde in de vorige hoofdstukken al, zonder dat je het merkte. Hier zie je het kort. Het volledige verhaal, met bind, singleton en contextual binding, staat in de les over de service container.

<!-- slide 39 -->

### Wie maakt de action aan

*method injection*

```php
public function store(StoreQuoteRequest $request, Project $project, SendQuoteAction $sendQuote)
```

Nergens in de controller staat `new SendQuoteAction()`. Je typt de klasse als parameter, en de service container van Laravel maakt ze aan. Dat heet method injection.

- **[Dependency injection en de service container](/nl/the-agency/04-service-container)**: Auto resolving, bind, singleton en contextual binding
- **[Service Container in de docs](https://laravel.com/docs/13.x/container)**: De volledige uitleg van Laravel zelf

> **Toelichting:** Auto resolving werkt voor klassen waarvan de container alle constructor-parameters zelf kan maken. Vraagt een klasse een string of een interface, dan moet je de container vertellen wat hij moet geven. Dat doe je met een binding, zie de volgende slides.

<!-- slide 40 -->

### Constructor injection

*app/Actions/Quotes/SendQuoteAction.php*

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

Heeft de action zelf iets nodig, zoals een PDF-renderer, dan vraagt ze het in haar constructor. De container bouwt eerst de renderer, dan de action, dan roept hij de controller aan. Je schrijft nergens een ketting van `new`.

> **Toelichting:** Constructor promotion (private PdfRenderer $pdf in de constructor) maakt de property en vult ze in één keer. Het bestaat sinds PHP 8.0 en is de standaard in moderne Laravel-code.

<!-- slide 41 -->

### Een interface als contract

*app/Contracts/PdfRenderer.php*

```php
#[Bind(BrowsershotPdfRenderer::class)]
#[Bind(FakePdfRenderer::class, environments: ['testing'])]
interface PdfRenderer
{
    public function render(string $view, array $data): string;
}
```

- Gewoon draait de echte renderer, die een browser opstart om de PDF te maken.
- In je tests krijg je de fake: snel, en zonder Chrome op de testserver.
- De action vraagt een PdfRenderer en weet niet welke. Het Bind-attribuut staat op de interface, dus geen service provider nodig.

> **Toelichting:** Zonder attribuut schrijf je in AppServiceProvider::register() $this->app->bind(PdfRenderer::class, BrowsershotPdfRenderer::class). Die vorm zie je in de meeste bestaande projecten. BrowsershotPdfRenderer en FakePdfRenderer zijn hier verzonnen klassen, de eerste zou spatie/browsershot gebruiken.

<!-- slide 42 -->

### Wanneer een interface loont

- Als er echt twee implementaties zijn: Mollie en Stripe, of een echte en een fake PDF-renderer.
- Voor een externe partij die je in tests niet wil aanspreken.
- Voor een package dat je aan andere projecten geeft.
- Niet voor elke action. Een SendQuoteActionInterface met precies één SendQuoteAction erachter is dubbel zoveel bestanden voor niets: een klasse kan je in een test ook zonder interface vervangen.

> **Toelichting:** Laravel zelf heeft veel interfaces in Illuminate\Contracts, zoals de Mailer of de Cache. Die bestaan omdat Laravel echt meerdere implementaties heeft: SMTP, Postmark, log. Kan je voor je eigen interface geen tweede implementatie noemen, dan heb je ze waarschijnlijk niet nodig.

<!-- slide 43 -->

## 6. Repositories

> **Toelichting:** Het repository pattern kom je tegen in tutorials, in andere frameworks en in projecten die je overneemt. Dit hoofdstuk helpt je beslissen wanneer het iets oplevert, en wanneer je beter gewoon Eloquent gebruikt.

<!-- slide 44 -->

### Het repository pattern

*app/Repositories/QuoteRepository.php*

```php
interface QuoteRepository
{
    public function openFor(Project $project): Collection;
}
```

*app/Repositories/EloquentQuoteRepository.php*

```php
public function openFor(Project $project): Collection
{
    return $project->quotes()->where('status', QuoteStatus::Sent)->get();
}
```

Een repository verbergt waar je objecten vandaan komen. Meer bij [Martin Fowler](https://martinfowler.com/eaaCatalog/repository.html).

> **Toelichting:** In Symfony met Doctrine is een repository standaard. Doctrine is een data mapper: je objecten weten niet hoe ze zichzelf opslaan. Eloquent is active record: Quote::find() en $quote->save() zitten al op het model, en daarom voegt een repository er minder toe.

<!-- slide 45 -->

### Bovenop Eloquent loont het zelden

- Eloquent is al een laag boven je database. Een repository erover is een laag boven een laag.
- Elke nieuwe vraag wordt een methode: `openForWithLinesPaginated()`.
- Je verliest eager loading, scopes en paginatie, of je bouwt ze na in elke repository.
- "Stel dat we ooit naar MongoDB gaan." Dan vervang je veel meer dan je queries, en het gebeurt bijna nooit.
- Stel: je neemt een project over met een repository, een interface en een binding per model. Veertig bestanden die Quote::find() doorgeven.

> **Toelichting:** Het testargument hoor je vaak: met een repository kan je de database mocken. In Laravel test je bijna altijd tegen een echte database met RefreshDatabase, en dat is betrouwbaarder dan een mock. Zie de les over testen met Pest.

<!-- slide 46 -->

### Wat je in de plaats doet

*app/Models/Quote.php*

```php
#[Scope]
protected function open(Builder $query): void
{
    $query->where('status', QuoteStatus::Sent);
}

$project->quotes()->open()->with('lines')->latest()->paginate();
```

Een local scope geeft een vraag een naam uit het domein, en je houdt alles van Eloquent erbij: `with`, `latest`, `paginate`. In Laravel 13 met het [Scope-attribuut](https://laravel.com/docs/13.x/eloquent#local-scopes).

> **Toelichting:** In oudere projecten zie je scopes als public function scopeOpen(Builder $query). Die schrijfwijze werkt nog steeds, het Scope-attribuut is de nieuwere vorm.

<!-- slide 47 -->

### Wanneer wel

| Situatie | Wat de repository verbergt |
| --- | --- |
| Producten komen uit het ERP van de klant | Urls, tokens, paginatie van hun API |
| Dezelfde data uit twee bronnen | Eerst de cache, dan de API, of een oude en een nieuwe database |
| Een legacy database die je niet mag aanpassen | Kolommen als `KLNT_NM_1` en datums als tekst |
| Een trage of betalende bron in je tests | Een fake repository in de container |

De rode draad: de bron is niet Eloquent, of je vertrouwt ze niet. Dan zit alle kennis over die bron in één klasse.

> **Toelichting:** Een legacy database hoeft niet altijd een repository: je kan ook een Eloquent model op de oude tabel zetten met $table, $primaryKey en accessors. Een repository loont pas als de vorm van de data echt niet op een model past.

<!-- slide 48 -->

### Een repository die loont

*app/Repositories/ErpProductRepository.php*

```php
public function all(): Collection
{
    return Http::withToken(config('services.erp.token'))
        ->get(config('services.erp.url').'/products')
        ->throw()
        ->collect('data')
        ->map(fn (array $product) => ProductData::fromErp($product));
}
```

De repository haalt de producten op met de [HTTP client](https://laravel.com/docs/13.x/http-client), gooit een exception als het ERP een fout geeft, en zet elke rij om naar `ProductData`. De rest van de app ziet nooit de JSON van het ERP.

> **Toelichting:** ProductData::fromErp() is een named constructor: een static methode die de JSON van het ERP omzet naar een object. throw() gooit een exception bij een 4xx of 5xx. Zonder throw() krijg je een lege collectie en merk je niet dat het ERP plat lag.

<!-- slide 49 -->

### Wanneer loont een repository het meest?

- A. Voor elk Eloquent model, voor de consistentie
- B. Als de producten uit het ERP van de klant komen
- C. Als je later misschien naar MongoDB wil
- D. Om queries sneller te maken

**Antwoord:** B. Als de producten uit het ERP van de klant komen

Dan verbergt hij iets wat de rest van je app niet hoeft te weten: urls, tokens en de vorm van hun JSON. Consistentie klinkt goed, maar een repository per model verbergt vooral Eloquent, en je verliest eager loading en scopes. En sneller wordt een query er niet van: het blijft dezelfde query, in een extra klasse.

> **Toelichting:** Snelheid komt uit indexes, eager loading en caching, zie de les over Eloquent queries. Een repository kan wel de plek zijn waar je cache zit, als de bron traag of betalend is. Dan verbergt hij iets nuttigs.

<!-- slide 50 -->

## 7. Projectstructuur

> **Toelichting:** Een structuur werkt alleen als het hele team ze volgt. Dit laatste hoofdstuk gaat over afspraken: waar dingen staan, hoe ze heten, en wanneer je al die lagen niet nodig hebt.

<!-- slide 51 -->

### Een echte projectstructuur

| Map | Wat erin zit | Voorbeeld |
| --- | --- | --- |
| `app/Http/Controllers` | HTTP in en uit | QuotesController |
| `app/Http/Requests` | Validatie en autorisatie | StoreQuoteRequest |
| `app/Actions/Quotes` | Eén use case per klasse | SendQuoteAction |
| `app/Data` | DTOs | QuoteData |
| `app/Models` | Eloquent en regels over eigen data | Quote, QuoteLine |
| `app/Enums` | Statussen en vaste lijsten | QuoteStatus |
| `app/Services` | Externe partijen en technieken | PdfRenderer |

> **Toelichting:** app/Data, app/Actions en app/Services maakt Laravel niet zelf aan, die mappen maak je. Composer vindt ze vanzelf, omdat alles onder app/ in de namespace App valt. In de les over domain-driven design zie je een alternatief: een map per domein.

<!-- slide 52 -->

### Afspraken in je team

- Kies execute of handle, en zet de keuze in de README.
- Een action heet altijd werkwoord, zelfstandig naamwoord, Action.
- Geen `Helpers.php` of `Utils`: elke functie heeft een betere plek.
- Laat een [architectuurtest](https://pestphp.com/docs/arch-testing) de afspraken afdwingen: de build faalt als iemand ze breekt.

*tests/ArchTest.php*

```php
arch('actions')
    ->expect('App\Actions')
    ->toHaveSuffix('Action')
    ->toHaveMethod('execute');
```

> **Toelichting:** arch() komt van Pest en bekijkt je code zonder ze uit te voeren. Je kan ook testen dat niets in app/Actions een Request gebruikt, of dat controllers de DB-facade niet aanspreken. Meer in de les over fakes, mocks en architectuurtests.

<!-- slide 53 -->

### Niet elke controller

```php
public function update(UpdateProjectRequest $request, Project $project): RedirectResponse
{
    $project->update($request->validated());

    return redirect()->route('projects.show', $project);
}
```

Een naam wijzigen is geen use case. Een Form Request en `update()` in de controller is hier de juiste architectuur. Een action loont als een tweede plek hetzelfde wil doen, of als de methode niet meer op je scherm past.

> [!TIP]
> Begin met een Form Request, voor elke controller. De action komt als de logica groeit.

> **Toelichting:** Architectuur heeft een prijs: meer bestanden, meer namen, meer springen tussen bestanden. Die prijs betaal je graag voor een use case met regels, niet voor een update van drie velden. Twijfel je, begin simpel en refactor als de tweede plek komt.

<!-- slide 54 -->

### Opdracht: Maak een controller dun

In je eigen Agency-project. Kies de dikste controllermethode die je hebt.

1. Validatie en autorisatie naar een Form Request (slide 13 tot 16)
2. Het werk naar een action met getypeerde input, zonder Request (slide 20 tot 23)
3. Regels over één model naar dat model (slide 25)
4. Klaar als de methode onder de tien regels zit, alles nog werkt, en een Pest-test de action rechtstreeks aanroept zonder HTTP
5. Vast? De Form Requests staan in de validation docs op laravel.com, de actions in Laravel Beyond CRUD

> **Toelichting:** Begin met een Pest-test voor de controller zoals hij nu is, zodat je na elke stap ziet dat alles nog werkt. Daarna de Form Request: die stap is klein en je ziet meteen resultaat. Schrijf de action als laatste, dan weet je al welke data ze nodig heeft.

## Begrippen

- **Fat controller**: Een controller die valideert, autoriseert, rekent, opslaat en mails stuurt, allemaal in één methode. Werkt perfect tot je dezelfde logica nodig hebt in een API, een job of een test. (slide 6)
- **Thin controller**: Een controller die alleen HTTP vertaalt: de request aannemen, een action aanroepen, een response teruggeven. Vijf regels, geen businesslogica. (slide 32)
- **Form Request**: Een klasse die validatie en autorisatie van één request bundelt, met rules() en authorize(). Laravel draait hem voor je controller, dus in de controller is de input al gecontroleerd. (slide 13)
- **authorize()**: De methode in een Form Request die zegt of deze gebruiker dit mag. Geeft ze false terug, dan stuurt Laravel een 403 en draait de controller niet. (slide 14)
- **validated()**: Geeft alleen de velden terug die in rules() staan en door de validatie kwamen. Wat een gebruiker extra meestuurt, valt eruit. (slide 16)
- **Action**: Een klasse met één use case, zoals SendQuoteAction, en één publieke methode (execute of handle). Krijgt getypeerde input, geen Request, en kan dus ook vanuit een job, een command of een test draaien. (slide 20)
- **Service class**: Een klasse die een externe partij of een techniek inpakt, zoals een PDF-renderer of de API van een ERP. Groepeert methodes rond één ding, geen use case. Groeit makkelijk uit tot een klasse van 800 regels. (slide 27)
- **DTO**: Data transfer object: een klein object met getypeerde properties dat data van de ene laag naar de andere brengt, zoals QuoteData van de Form Request naar de action. Geen gedrag, geen database. (slide 24)
- **Side effect**: Alles wat een use case doet buiten zijn eigen data: een mail, een Slack-bericht, een call naar een API. Hoort in de action of in een listener, niet in de controller. (slide 28)
- **afterCommit**: Zegt tegen een queued mail, job of listener dat hij pas vertrekt als de transaction gecommit is. Anders kan de worker een offerte zoeken die nog niet bestaat. (slide 29)
- **Resource controller**: Een controller met de zeven CRUD-methodes: index, create, store, show, edit, update en destroy. Alles wat daar niet in past, krijgt een eigen controller. (slide 35)
- **Method injection**: Een dependency als parameter van een controllermethode typen, zoals SendQuoteAction $sendQuote. De service container maakt hem aan en geeft hem mee. (slide 39)
- **Constructor injection**: Dependencies van een klasse in haar constructor vragen, met constructor promotion. De container vult ze in als hij de klasse maakt. (slide 40)
- **Contract**: Een interface die zegt wat een klasse kan, zonder hoe. Met #\[Bind\] of bind() in een service provider kiest Laravel de implementatie, zodat je in tests een fake kan geven. (slide 41)
- **Repository pattern**: Een klasse die doet alsof ze een collectie objecten is en verbergt waar die vandaan komen. Bovenop Eloquent loont het zelden, voor een externe API, meerdere bronnen of een legacy database wel. (slide 44)
- **Query scope**: Een herbruikbare voorwaarde op een model, in Laravel 13 met het #\[Scope\]-attribuut. Houdt queries leesbaar zonder de rest van Eloquent te verliezen. (slide 46)

## Links en bronnen

### In de presentatie

- [Form Request Validation](https://laravel.com/docs/13.x/validation#form-request-validation) (slide 13): authorize, rules, validated en safe
- [Laravel Beyond CRUD](https://laravel-beyond-crud.com/) (slide 20): Actions en data objects zoals Spatie ze gebruikt
- [Laravel 11 release notes](https://laravel.com/docs/11.x/releases) (slide 22): make:class, make:enum en make:interface
- [spatie/laravel-data](https://spatie.be/docs/laravel-data) (slide 24)
- [Queueing mail](https://laravel.com/docs/13.x/mail#queueing-mail) (slide 29): Mail::queue en afterCommit
- [single action controllers](https://laravel.com/docs/13.x/controllers#single-action-controllers) (slide 34)
- [Spatie guidelines: Laravel](https://spatie.be/guidelines/laravel) (slide 35): CRUD-methodes in controllers, een aparte controller voor de rest
- [Service Container](https://laravel.com/docs/13.x/container) (slide 39): Method injection en het Bind-attribuut op een interface
- [Repository](https://martinfowler.com/eaaCatalog/repository.html) (slide 44): Het pattern zoals Martin Fowler het beschrijft
- [Local scopes](https://laravel.com/docs/13.x/eloquent#local-scopes) (slide 46): Het Scope-attribuut in Laravel 13
- [HTTP Client](https://laravel.com/docs/13.x/http-client) (slide 48): withToken, throw en collect
- [Pest: architecture testing](https://pestphp.com/docs/arch-testing) (slide 52): toHaveSuffix en toHaveMethod

### Bronnen

- [Controllers](https://laravel.com/docs/13.x/controllers): Single action controllers en make:controller --invokable
