# Factory en singleton

Twee design patterns die je in Laravel elke dag gebruikt: factories en de managers achter Storage::disk() en Mail::mailer(), en de singleton, liefst via de container.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, design-patterns, architecture

Slides: <https://slides.mctantwerp.be/nl/the-agency/05-design-patterns>

<!-- slide 2 -->

## Agenda

1. Design patterns
2. Factory
3. Managers en drivers
4. Singleton
5. Singletons in Laravel
6. Octane

> **Toelichting:** De eerste drie hoofdstukken gaan over factories, de laatste drie over singletons. Het rode draadje: het klassieke boek zegt het ene, en Laravel lost hetzelfde probleem op met de container. Het laatste hoofdstuk over Octane is kort, maar het is het stuk dat in productie het meeste kwaad doet.

<!-- slide 3 -->

## Na deze les kan je

- Uitleggen wat een design pattern is, en patterns herkennen die Laravel al gebruikt
- Een simple factory schrijven die met match de juiste implementatie kiest
- Uitleggen hoe Storage::disk() en Mail::mailer() werken, en zelf een manager met drivers bouwen
- Het factory pattern onderscheiden van een model factory
- Uitleggen waarom de klassieke singleton een anti-pattern is, en hem vervangen door een singleton via de container
- Een singleton herkennen die onder Octane data lekt, en hem oplossen

> **Toelichting:** Gebruik deze lijst na de les als checklist. Kan je een punt niet uitleggen aan een klasgenoot, ga dan terug naar dat hoofdstuk. De huistaak op het einde raakt de eerste vijf punten.

<!-- slide 4 -->

## 1. Design patterns

> **Toelichting:** Patterns zijn vooral een woordenschat. In een code review of een technisch gesprek bij een sollicitatie zegt 'maak er een factory van' in vier woorden wat anders een halve pagina kost. Je hoeft ze niet allemaal te kennen, wel de handvol die je elke dag in Laravel tegenkomt.

<!-- slide 5 -->

### Een design pattern

- Een design pattern is een bekende oplossing voor een probleem dat steeds terugkomt.
- Het is geen code die je kopieert, maar een vorm die je herkent en aanpast aan je eigen project.
- De bekendste staan in het boek van de Gang of Four uit 1994: 23 patterns, met voorbeelden in C++ en Smalltalk.
- De namen zijn het nuttigste deel. "Dat is een factory" zegt meer dan een halve pagina uitleg.

> **Toelichting:** De Gang of Four zijn Erich Gamma, Richard Helm, Ralph Johnson en John Vlissides. Het boek heet Design Patterns: Elements of Reusable Object-Oriented Software. Het is taai om te lezen en je hoeft het niet van voor tot achter te kennen. Voor PHP-voorbeelden per pattern is refactoring.guru een toegankelijke plek om verder te lezen.

<!-- slide 6 -->

### Je gebruikt ze al

- **Chain of responsibility**: Middleware. Elke laag handelt af of geeft door.
- **Observer**: Events en listeners, model observers.
- **Builder**: `User::where(…)->orderBy(…)->get()`
- **Strategy**: Drivers: dezelfde Cache, met redis of met file.
- **Factory**: `Storage::disk('s3')`, `Mail::mailer()`
- **Singleton**: De container zelf, en alles wat je er met `singleton()` in zet.

Let op: een Laravel-facade is niet de facade uit het boek. Daar is een facade een eenvoudige ingang tot een ingewikkeld systeem. Laravel's facades zijn static proxies naar de container.

> **Toelichting:** Strategy en factory liggen dicht bij elkaar. Strategy gaat over verwisselbaar gedrag achter één interface, factory over wie beslist welke van die implementaties je krijgt. In Laravel werken ze samen: de manager is de factory, de drivers zijn de strategieën. Observer kom je tegen in de les over Eloquent-modellen, bij model events en observers.

<!-- slide 7 -->

### Middleware is een voorbeeld van welk pattern?

- A. Chain of responsibility
- B. Singleton
- C. Factory method
- D. Observer

**Antwoord:** A. Chain of responsibility

Elke middleware krijgt de request, doet zijn ding en geeft door met $next, of stopt de ketting en geeft zelf een response. Observer lijkt ook te passen, want middleware reageert op een request, maar bij observer schrijven objecten zich in op een gebeurtenis en worden ze allemaal verwittigd. Hier gaat de request door een rij, en elke schakel mag de rest tegenhouden.

> **Toelichting:** Ook de pipeline achter middleware kan je zelf gebruiken: Illuminate\Pipeline\Pipeline stuurt een object door een lijst stappen, precies zoals de kernel met een request doet. Handig voor een reeks bewerkingen op een order of een import, elk in een eigen class.

<!-- slide 8 -->

## 2. Factory

> **Toelichting:** Een factory lost een heel concreet probleem op: code die moet kiezen welke class ze gebruikt, en die keuze op meerdere plekken herhaalt. Zodra je in een project voor de tweede keer dezelfde if-ketting met new schrijft, is het tijd voor een factory.

<!-- slide 9 -->

### Het probleem

```php
if ($client->export_format === 'peppol') {
    $exporter = new PeppolExporter(config('services.peppol.endpoint'));
} elseif ($client->export_format === 'csv') {
    $exporter = new CsvExporter();
} else {
    $exporter = new PdfExporter();
}

$file = $exporter->export($invoice);
```

Stel: de ene klant wil zijn facturen als pdf, een andere via Peppol, en de boekhouder wil een csv. Dit blok staat in de controller, in de job die elke maand exporteert en in een Artisan-commando.

> **Toelichting:** Peppol is een Europees netwerk om e-facturen tussen bedrijven uit te wisselen. In België is het sinds 1 januari 2026 verplicht voor facturen tussen bedrijven. Een Peppol-export is dus geen exotische vraag meer: in een agency krijg je hem van elke klant die facturen verstuurt.

<!-- slide 10 -->

### Wat er mis is

- Drie plekken met dezelfde if-ketting. Bij een vierde formaat vergeet je er gegarandeerd één.
- Een tikfout zoals peppl valt stil door naar de else, en de klant krijgt een pdf.
- Elke plek moet weten dat een PeppolExporter een endpoint nodig heeft.
- De code die exporteert, is vermengd met de code die kiest.

> **Toelichting:** Het tweede punt is het gevaarlijkste: niemand krijgt een foutmelding. De klant krijgt een pdf in plaats van een Peppol-factuur, en je hoort het pas als zijn boekhouder belt. Code die bij een onverwachte waarde luid faalt, is bijna altijd beter dan code die stil een standaard kiest.

<!-- slide 11 -->

### Eerst een interface

#### De formaten

```php
enum ExportFormat: string
{
    case Pdf = 'pdf';
    case Peppol = 'peppol';
    case Csv = 'csv';
}
```

Een enum in plaats van losse strings. Een tikfout wordt een fout van PHP.

#### Wat elke exporter kan

```php
interface InvoiceExporter
{
    public function export(Invoice $invoice): string;
}
```

Elke exporter belooft een methode export(), die het pad naar het bestand teruggeeft.

> **Toelichting:** Met een backed enum en een enum cast in casts() van het Client-model krijg je bij het uitlezen meteen een ExportFormat terug. Een waarde uit de database die niet in de enum staat, geeft een ValueError. Ook dat is luid falen, en dat is goed.

<!-- slide 12 -->

### Simple factory

```php
class InvoiceExporterFactory
{
    public function make(ExportFormat $format): InvoiceExporter
    {
        return match ($format) {
            ExportFormat::Pdf => new PdfExporter(),
            ExportFormat::Peppol => new PeppolExporter(config('services.peppol.endpoint')),
            ExportFormat::Csv => new CsvExporter(),
        };
    }
}
```

1. Eén methode die een formaat krijgt en een InvoiceExporter teruggeeft.
2. `match` is strikt: vergeet je een case, dan krijg je een UnhandledMatchError.

> **Toelichting:** Simple factory staat niet in het boek van de Gang of Four. Het is een eenvoudiger variant die in de praktijk veel vaker voorkomt dan het originele factory method. Zet je factories in dezelfde map als de exporters, bijvoorbeeld app/Exports, zodat ze samen gevonden worden.

<!-- slide 13 -->

### Gebruiken

```php
public function show(Invoice $invoice, InvoiceExporterFactory $exporters): BinaryFileResponse
{
    $file = $exporters->make($invoice->client->export_format)->export($invoice);

    return response()->download($file);
}
```

1. De factory komt binnen via method injection, zoals elke dependency.
2. Kiezen en exporteren in één regel. De controller kent geen enkele exporter bij naam.

> **Toelichting:** De factory is een gewone class zonder constructor, dus de container bouwt hem zonder dat je iets registreert. Dat is auto resolving uit de les over de container. In een job gaat het net zo: type-hint de factory in handle().

<!-- slide 14 -->

### Wat je wint

- Eén plek die weet welke class bij welk formaat hoort.
- Een nieuw formaat is één case in de enum en één regel in de factory, niet drie if-blokken.
- De code die exporteert, kent alleen de interface InvoiceExporter.
- Een vergeten formaat crasht meteen met een UnhandledMatchError, in plaats van stil een pdf te maken.

> **Toelichting:** Een factory is ook de natuurlijke plek voor een test: geef elk formaat mee en controleer welke class je terugkrijgt. Met een dataset in Pest is dat één test voor alle formaten samen. Zie de les over testen voor datasets.

<!-- slide 15 -->

### Laat de container bouwen

```php
public function __construct(
    private Container $container,
) {}

public function make(ExportFormat $format): InvoiceExporter
{
    return $this->container->make(match ($format) {
        ExportFormat::Pdf => PdfExporter::class,
        ExportFormat::Peppol => PeppolExporter::class,
        ExportFormat::Csv => CsvExporter::class,
    });
}
```

1. De factory vraagt de container zelf, via constructor injection.
2. match kiest de class, de container bouwt ze. PeppolExporter haalt zijn endpoint zelf met `#[Config]`.

> **Toelichting:** Een container in een constructor injecteren is hier prima, omdat de factory met gewone auto resolving gebouwd wordt, dus elke keer opnieuw. Maak je er een singleton van en draai je onder Octane, dan houdt hij de container van de eerste request vast. De Octane-docs raden dat af. Meer daarover in hoofdstuk 6.

<!-- slide 16 -->

### Factory method

Het origineel uit het boek: de basisclass weet wat er moet gebeuren, maar laat één methode open.

```php
abstract class InvoiceExport
{
    abstract protected function createExporter(): InvoiceExporter;

    public function run(Invoice $invoice): string
    {
        return $this->createExporter()->export($invoice);
    }
}
```

1. De factory method: abstract, dus elke subclass moet hem invullen.
2. run() gebruikt hem zonder te weten welke exporter eruit komt.

> **Toelichting:** In het boek heet de basisclass de creator en de subclass de concrete creator. Je herkent het pattern aan een abstracte of overschrijfbare methode die met create begint en een object teruggeeft. Laravel's eigen voorbeeld zie je in het volgende hoofdstuk: elke manager heeft zulke methodes.

<!-- slide 17 -->

### Een subclass beslist

```php
class AccountantExport extends InvoiceExport
{
    protected function createExporter(): InvoiceExporter
    {
        return new CsvExporter();
    }
}
```

`(new AccountantExport())->run($invoice)` geeft een csv. De basisclass heeft geen idee welke exporter er gebruikt werd, de subclass heeft het beslist.

> **Toelichting:** Voor elke variant een nieuwe subclass: AccountantExport voor csv, GovernmentExport voor Peppol. Dat schaalt slecht als er veel varianten zijn, en een class kan maar van één basisclass erven. Daarom zie je dit pattern vooral in frameworks, waar anderen de subclasses schrijven.

<!-- slide 18 -->

### Welke kies je

- Simple factory: één class met een match, die je injecteert waar je hem nodig hebt.
- Factory method: een subclass per variant, via overerving.
- In je eigen code wint bijna altijd de simple factory. Ze is korter en je test ze los.
- Een subclass maken voor één methode is een zware keuze: je zit vast aan de basisclass en aan zijn andere methodes.

> **Toelichting:** Dit heet vaak composition over inheritance: liever een object meegeven dan erven. Overerving is niet verboden, maar het koppelt twee classes voor altijd. Een geïnjecteerde factory kan je in een test vervangen, een basisclass niet.

<!-- slide 19 -->

### Je voegt ExportFormat::Ubl toe, maar vergeet de factory aan te passen. Wat gebeurt er bij een klant met ubl?

- A. Een UnhandledMatchError
- B. Hij krijgt de PdfExporter
- C. make() geeft null terug
- D. Laravel kiest zelf een exporter

**Antwoord:** A. Een UnhandledMatchError

match zonder default gooit een UnhandledMatchError als geen enkele arm past. De PdfExporter is het verleidelijke antwoord omdat de oude if-ketting dat deed, maar match heeft geen else tenzij je zelf een default schrijft. Luid falen is hier goed: beter een fout in je logs dan een klant die stil het verkeerde formaat krijgt.

> **Toelichting:** Static analysis zoals PHPStan of Larastan waarschuwt al voor je de code draait als een match op een enum niet alle cases dekt. Een reden te meer om enums te gebruiken in plaats van strings, en om geen default-arm toe te voegen alleen om de fout weg te krijgen.

<!-- slide 20 -->

### Twee keer factory

#### Factory pattern

- Productiecode.
- Kiest welke class je krijgt.
- Het resultaat doet het werk: exporteren, versturen, opslaan.

#### Model factory

- Tests en seeders, in database/factories.
- Bouwt modellen met nepdata.

```php
User::factory()
    ->count(3)
    ->unverified()
    ->create();
```

> **Toelichting:** De naam komt uit Rails en andere frameworks, waar test data builders al lang factories heetten. Model factories staan in database/factories en worden nooit in productiecode gebruikt. Zie je User::factory() in een controller, dan is er iets mis.

<!-- slide 21 -->

### Een model factory is een builder

`User::factory()` kiest geen implementatie. Je stapelt instellingen op: hoeveel, welke state, welke relaties. Pas `create()` bouwt het resultaat. Dat is het builder pattern, ook al heet het factory. Zelfde woord, ander probleem.

[Docs: Eloquent Factories](https://laravel.com/docs/13.x/eloquent-factories)

> **Toelichting:** Een state zoals unverified() is een methode in je UserFactory die een deel van de attributen aanpast. Je kan ze combineren en ook relaties toevoegen met has() en for(). De les over testen gaat er dieper op in.

<!-- slide 22 -->

### User::factory()->count(3)->unverified()->create() is vooral een voorbeeld van welk pattern?

- A. Builder
- B. Factory method
- C. Singleton
- D. Chain of responsibility

**Antwoord:** A. Builder

Je bouwt het resultaat stap voor stap op, en create() maakt het af: dat is een builder. Factory method is verleidelijk door de naam, maar er beslist geen subclass welke implementatie je krijgt. Het is altijd een User.

> **Toelichting:** De query builder werkt net zo: where(), orderBy() en limit() stapelen op, en get() voert pas uit. Daarom kan je een query in stukken opbouwen, bijvoorbeeld met when() voor een filter dat alleen soms geldt.

<!-- slide 23 -->

## 3. Managers en drivers

> **Toelichting:** Managers zijn Laravel's antwoord op factories die je in elk project nodig hebt: welke disk, welke mailer, welke cache. Als je snapt hoe een manager werkt, snap je meteen hoe je een package als Socialite uitbreidt met een eigen driver, en hoe je dat patroon in je eigen code gebruikt.

<!-- slide 24 -->

### Managers in Laravel

```php
Storage::disk('s3')->put("invoices/{$invoice->number}.pdf", $pdf);

Mail::mailer('postmark')->to($client)->send(new InvoiceMail($invoice));

Socialite::driver('microsoft')->redirect();

Cache::store('redis')->get('exchange-rates');
```

Vier facades, één patroon: je vraagt een driver bij naam, of je krijgt de default uit config. `Socialite::driver()` ken je uit de les over SSO.

> **Toelichting:** Achter elk van deze facades zit een manager-class: FilesystemManager, MailManager, SocialiteManager en CacheManager. Zonder naam krijg je de default: Storage::put() gaat naar de disk uit FILESYSTEM_DISK, Mail::to() naar de default mailer.

<!-- slide 25 -->

### Hoe een manager een driver geeft

```php
public function driver(?string $driver = null): mixed
{
    $driver ??= $this->getDefaultDriver();

    return $this->drivers[$driver] ??= $this->createDriver($driver);
}
```

1. Geen naam meegegeven? Dan de default, die de manager uit config leest.
2. Staat de driver al in `$this->drivers`, dan krijg je dat object. Anders wordt hij gemaakt en onthouden.

Vereenvoudigd uit [Illuminate\\Support\\Manager](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Support/Manager.php).

> **Toelichting:** De echte driver() in Illuminate\Support\Manager zet ook een enum om naar een string en gooit een fout als er geen default is. De MailManager en de FilesystemManager erven niet van Manager, maar werken op dezelfde manier met een eigen methode: mailer() en disk().

<!-- slide 26 -->

### Hoe een manager een driver maakt

```php
protected function createDriver(string $driver): mixed
{
    if (isset($this->customCreators[$driver])) {
        return $this->callCustomCreator($driver);
    }

    $method = 'create'.Str::studly($driver).'Driver';

    return $this->$method();
}
```

1. Eerst de drivers die iemand zelf toevoegde met `extend()`.
2. Anders een methode zoals `createS3Driver()` of `createLogDriver()`: een factory method per driver.

> **Toelichting:** Str::studly maakt van een naam met streepjes of underscores StudlyCase: linkedin-openid wordt LinkedinOpenid. Zo komt Socialite aan createLinkedinOpenidDriver(). Bestaat de methode niet en is er geen extend(), dan krijg je een InvalidArgumentException met Driver [naam] not supported.

<!-- slide 27 -->

### Factory en singleton in één

- De eerste keer maakt driver() het object. Dat is de factory.
- Daarna geeft hij hetzelfde object terug: één object per driver, zolang de manager leeft.
- De manager zelf zit als singleton in de container.
- Daarom geeft `Storage::disk('s3')` twee keer in dezelfde request hetzelfde object.

> **Toelichting:** Dit is ook waarom een manager onder Octane oplet: de drivers blijven bestaan van request tot request. Laravel's eigen managers zijn daarop voorzien. Bouw je zelf een manager, zet dan geen user- of requestdata in een driver.

<!-- slide 28 -->

### Een eigen driver

*AppServiceProvider::boot()*

```php
Mail::extend('mailchimp', function (array $config = []) {
    $client = new ApiClient;

    $client->setApiKey($config['key']);

    return new MailchimpTransport($client);
});
```

1. `extend()` zet een closure bij de custom creators. createDriver() vindt ze als eerste.
2. De closure krijgt de config van de mailer mee en geeft een transport terug.

Daarna kan een mailer in `config/mail.php` met `'transport' => 'mailchimp'` werken. [Docs](https://laravel.com/docs/13.x/mail#custom-transports)

> **Toelichting:** De sso-les deed hetzelfde bij Socialite: een listener op SocialiteWasCalled roept extendSocialite() op, en dat is achter de schermen een extend() op de SocialiteManager. Storage::extend() werkt op dezelfde manier, voor een disk bij een provider die Laravel niet kent.

<!-- slide 29 -->

### Je eigen manager

```php
class SmsManager extends Manager
{
    public function getDefaultDriver(): string
    {
        return $this->config->get('services.sms.driver', 'log');
    }

    protected function createTwilioDriver(): SmsSender
    {
        return new TwilioSmsSender(
            $this->config->get('services.twilio.sid'),
            $this->config->get('services.twilio.token'),
        );
    }
}
```

> **Toelichting:** Dit is dezelfde SmsSender-interface als in de les over de service container. Het verschil met #[Bind]: daar koos de omgeving één implementatie voor de hele app. Met een manager kies je per bericht, bijvoorbeeld een dure provider voor tweefactorcodes en een goedkope voor herinneringen.

<!-- slide 30 -->

### Wat SmsManager doet

- getDefaultDriver() leest in config welke driver de standaard is, met log als reserve.
- createTwilioDriver() bouwt een TwilioSmsSender. De naam volgt de regel: create, de naam van de driver in StudlyCase, Driver.
- Een createLogDriver() ziet er net zo uit en geeft een LogSmsSender terug.
- $this->config is de config-repository. Manager krijgt die zelf binnen in zijn constructor.

> **Toelichting:** Het bestand zet je bijvoorbeeld in app/Sms/SmsManager.php, naast de senders. Wil je Sms::send() schrijven zoals bij Mail, dan maak je een facade met getFacadeAccessor() die SmsManager::class teruggeeft. Dat is optioneel: injecteren werkt net zo goed.

<!-- slide 31 -->

### Registreren en gebruiken

#### AppServiceProvider::register()

```php
$this->app->singleton(SmsManager::class);
```

Eén manager voor de hele app, die zijn drivers onthoudt.

#### Ergens in je app

```php
$sms->driver('twilio')->send($phone, $message);

$sms->send($phone, $message);
```

Zonder driver() gaat de call naar de default, via \_\_call() in Manager.

> **Toelichting:** Zonder singleton zou elke plek die SmsManager vraagt een nieuwe manager krijgen, met een lege lijst drivers. Dan maakt hij elke driver opnieuw, en verlies je het voordeel van de cache in driver().

<!-- slide 32 -->

### Een onbekende driver

```php
$sms->driver('whatsapp');

// InvalidArgumentException: Driver [whatsapp] not supported.
```

Er is geen `createWhatsappDriver()` en geen `extend()` voor whatsapp, dus de manager geeft op. Komt de naam van een driver uit een url of een formulier, vang dat dan af voor het een 500 wordt. Dat doe je in de huistaak.

> **Toelichting:** Een alternatief voor een try-catch: beperk de route met whereIn('format', ['csv', 'json']). Dan komt een onbekende waarde nooit bij de manager, en krijgt de gebruiker vanzelf een 404. Dat is korter en je ziet op de route meteen welke formaten bestaan.

<!-- slide 33 -->

### Je roept Storage::disk('s3') twee keer op in dezelfde request. Wat krijg je?

- A. Twee keer hetzelfde object
- B. Twee nieuwe objecten
- C. Een fout, een disk mag maar één keer
- D. Hangt af van de config

**Antwoord:** A. Twee keer hetzelfde object

De FilesystemManager onthoudt elke disk die hij gemaakt heeft: de eerste keer is het een factory, daarna een singleton per disk. Twee nieuwe objecten klinkt logisch als je disk() leest als new, maar dan zou elke call een nieuwe S3-client opzetten, en dat is traag.

> **Toelichting:** Wil je toch een verse disk, bijvoorbeeld met andere credentials voor één klant, dan heeft Storage::build() een on-demand disk met een eigen config-array. Die wordt niet onthouden.

<!-- slide 34 -->

## 4. Singleton

> **Toelichting:** De singleton is het bekendste pattern uit het boek, en het meest bekritiseerde. Je komt hem tegen in oudere PHP-code, in WordPress-plugins en in packages. Weten waarom hij problemen maakt, helpt je om die code te lezen en om hem in je eigen code niet te schrijven.

<!-- slide 35 -->

### De klassieke singleton

```php
final class Settings
{
    private static ?Settings $instance = null;

    private function __construct() {}

    public static function getInstance(): Settings
    {
        return self::$instance ??= new Settings();
    }
}
```

1. Een private constructor: niemand buiten de class kan `new Settings()` doen.
2. Een static property onthoudt het object, en `getInstance()` maakt het maar één keer.

> **Toelichting:** final voorkomt dat een subclass de constructor weer publiek maakt. In oudere voorbeelden zie je ook een private __clone() en een __wakeup() die een exception gooit, zodat niemand een kopie kan maken via clone of unserialize. Dat laat zien hoeveel moeite het kost om een class echt uniek te houden.

<!-- slide 36 -->

### Waarom mensen het doen

- Eén gedeeld object: een config, een databaseconnectie, een logger.
- Overal bereikbaar, zonder iets door te geven.
- Wat duur is om te bouwen, bouw je maar één keer.
- Het staat in elke tutorial over design patterns, meestal als eerste.

> **Toelichting:** De redenen zijn goed, en de container bereikt ze alle drie ook. Het probleem zit in de uitvoering: de class regelt zelf dat hij uniek is, met een static property. Dat is waar de volgende slides over gaan.

<!-- slide 37 -->

### In een test

```php
it('toont prijzen in dollar voor Amerikaanse klanten', function () {
    Settings::getInstance()->set('currency', 'USD');

    $this->get('/prijzen')->assertSee('$');
});

it('toont prijzen in euro', function () {
    $this->get('/prijzen')->assertSee('€');
});
```

> [!WARNING]
> Pest draait je tests in hetzelfde PHP-proces, en een static property overleeft van test tot test. De tweede test faalt, maar alleen als de eerste ervoor draait: de currency staat nog op USD.

> **Toelichting:** Met pest --parallel verdeelt Pest je tests over processen, maar elk proces draait nog altijd veel tests na elkaar. Het probleem wordt dan grilliger: de test faalt alleen als de twee toevallig in hetzelfde proces terechtkomen. Zo'n test die soms faalt heet een flaky test, en die kost je vertrouwen in je hele testsuite.

<!-- slide 38 -->

### Waarom faalt de euro-test alleen als de dollar-test ervoor draait?

- A. De static property in Settings overleeft tussen de tests
- B. Pest draait tests in willekeurige volgorde
- C. De database wordt niet opgekuist
- D. De config is gecached

**Antwoord:** A. De static property in Settings overleeft tussen de tests

Settings::$instance is static, dus hij leeft zo lang als het PHP-proces, en Pest draait alle tests in één proces. Een vuile database is een verleidelijke verklaring, want dat geeft ook tests die van elkaar afhangen, maar hier komt er geen database aan te pas: het zit in het geheugen.

> **Toelichting:** Je kan dit oplossen met een reset-methode die je na elke test oproept, en dat zie je in oudere code ook gebeuren. Maar dan moet elke test eraan denken. De container lost het op zonder dat je eraan moet denken, zoals je in hoofdstuk 5 ziet.

<!-- slide 39 -->

### Een anti-pattern

- Global state: elke class kan de singleton lezen en aanpassen.
- Een verstopte dependency: je ziet niet in de constructor dat een class Settings gebruikt.
- Niet te wisselen in een test, want `Settings::getInstance()` is een static call die altijd dezelfde class raakt.
- State lekt van de ene test naar de volgende.

> **Toelichting:** Een anti-pattern is een oplossing die voor de hand ligt en populair is, maar meer problemen maakt dan ze oplost. Het woord zegt niet dat de klassieke singleton nooit werkt, wel dat er in bijna elke situatie een betere keuze bestaat. Het boek van de Gang of Four is zelf ouder dan de gewoonte om alles te testen.

<!-- slide 40 -->

**Herken hem, schrijf hem niet.**

Je komt hem tegen in oude code en in packages. In je eigen Laravel-code heb je hem niet nodig: de container doet wat een singleton belooft, zonder de nadelen.

> **Toelichting:** Herkennen is nuttig: zie je getInstance() in een package, dan weet je dat je die class niet zomaar kan vervangen in een test. Vaak kan je er dan een eigen class rond schrijven die je wel injecteert, en die de singleton intern gebruikt.

<!-- slide 41 -->

## 5. Singletons in Laravel

> **Toelichting:** Dit hoofdstuk verbindt deze les met de vorige. Je zag in de les over de container hoe singleton() en #[Singleton] werken. Hier zie je waarom dat de betere versie van het klassieke pattern is.

<!-- slide 42 -->

### Klassiek of via de container

#### Klassiek

```php
$currency = Settings::getInstance()
    ->get('currency');
```

De class regelt zelf dat er maar één is, met static state.

#### Via de container

```php
#[Singleton]
class Settings
{
    public function __construct(
        private array $values = [],
    ) {}
}
```

```php
public function __construct(
    private Settings $settings,
) {}
```

> **Toelichting:** Een default-waarde in de constructor, zoals array $values = [], vult de container zelf in als er niets anders is. Daarom kan de container deze class bouwen zonder binding, en kan een test er toch waarden aan meegeven met new Settings([...]).

<!-- slide 43 -->

### Wie beslist

Rechts is `Settings` een gewone class met een publieke constructor. Niet de class beslist dat er maar één is, de container beslist het. Dat is het hele verschil, en het maakt alles testbaar. Het is dezelfde `#[Singleton]` als in de les over de service container.

> [!TIP]
> Wie Settings nodig heeft, vraagt hem in de constructor. Zo zie je de dependency, en kan een test hem vervangen.

> **Toelichting:** Je kan dezelfde class op de ene plek als singleton binden en in een test als gewone instance. Dat kan omdat het een beslissing van de container is en niet van de class. Bij de klassieke singleton zit die beslissing in de class gebakken.

<!-- slide 44 -->

### Wisselen in een test

```php
it('toont prijzen in dollar voor Amerikaanse klanten', function () {
    $this->instance(Settings::class, new Settings([
        'currency' => 'USD',
    ]));

    $this->get('/prijzen')->assertSee('$');
});
```

1. Je zet je eigen Settings in de container. Elke class die Settings vraagt, krijgt dit object.
2. Na de test gooit Laravel de hele app weg. De euro-test krijgt een verse Settings.

> **Toelichting:** $this->instance() zet een object dat je zelf maakt in de container. $this->mock() doet hetzelfde met een Mockery-mock. Gebruik instance() als je een echte Settings met andere waarden wil, en mock() als je wil controleren welke methodes er opgeroepen worden.

<!-- slide 45 -->

### Wat je wint

- De class weet niet dat er maar één van bestaat.
- De container beslist, en een test kan dat overschrijven.
- Elke test krijgt een nieuwe app, dus ook een nieuwe singleton. Geen lekken tussen tests.
- Je ziet de dependency in de constructor.

> **Toelichting:** Het derde punt komt van Laravel's TestCase: die maakt voor elke test een nieuwe applicatie aan en ruimt ze daarna op. Een static property doet daar niet aan mee, want die hangt aan de class en niet aan de container.

<!-- slide 46 -->

### Welke versie van Settings kan je in een test wisselen zonder de class aan te passen?

- A. Settings::getInstance()
- B. #\[Singleton\] met Settings in de constructor
- C. Een static Settings::get()
- D. Een global $settings

**Antwoord:** B. #\[Singleton\] met Settings in de constructor

Alleen via de container kan een test er iets anders in zetten, met $this->instance() of $this->mock(). Een static Settings::get() lijkt handig omdat een facade er ook zo uitziet, maar een facade gaat via de container en een gewone static method niet.

> **Toelichting:** Zie je in een project een static helper-class die je niet kan vervangen, dan is een real-time facade een snelle uitweg: importeer Facades\App\Support\Settings in plaats van App\Support\Settings, en Laravel behandelt de class als facade. Zie de docs over facades.

<!-- slide 47 -->

## 6. Octane

> **Toelichting:** Octane is voor veel projecten nog geen standaard, maar hostingplatformen zoals Forge en Laravel Cloud ondersteunen het, dus de vraag komt. En de fout in dit hoofdstuk bestaat ook zonder Octane: een queue worker is net zo goed een proces dat blijft draaien.

<!-- slide 48 -->

### Terug naar het lab

In de les over de service container zag je in het lab [Zelfde object](/nl/the-agency/04-service-container#23) dat een singleton onder Octane of in een queue worker van request tot request hetzelfde object blijft. Onder PHP-FPM niet. In dit hoofdstuk zie je wat dat betekent als er data van een ingelogde user in dat object zit.

> **Toelichting:** Heb je de les over de service container overgeslagen, open dan eerst het lab daar. Het toont met Cart #1 en Cart #2 welk object elke resolve teruggeeft, zonder dat er data van een user in zit. Hier komt die data erbij.

<!-- slide 49 -->

### PHP-FPM en Octane

#### PHP-FPM

- Elke request boot de app opnieuw.
- register() en boot() draaien bij elke request.
- Na de response is alles weg.
- Een singleton leeft één request.

#### Octane

- De app boot één keer per worker.
- register() en boot() draaien één keer.
- De worker blijft, request na request.
- Een singleton leeft honderden requests.

> **Toelichting:** Octane draait op FrankenPHP, Swoole of RoadRunner. Een Octane-worker herstart standaard na 500 requests, precies om lekken te beperken. Lokaal start je het met php artisan octane:start, en --watch herstart de workers als je code wijzigt.

<!-- slide 50 -->

### Het lek

Kies singleton en Octane, en kijk welke winkelmand Bram en Chloé krijgen. Zet daarna scoped aan.

Een CartService die de ingelogde user in zijn constructor krijgt, als singleton of scoped, onder PHP-FPM of Octane. Requests van Anna, Bram, Anna, Chloé. Alleen singleton onder Octane lekt: wie na Anna binnenkomt, ziet de winkelmand van Anna.

> **Toelichting:** In de echte wereld is het nog verraderlijker: Octane draait meerdere workers, en elke worker onthoudt de user van zijn eigen eerste request. Twee bezoekers kunnen dus elk een andere vreemde winkelmand zien, afhankelijk van welke worker hun request krijgt.

<!-- slide 51 -->

### Wat er gebeurt

- De eerste request resolvet CartService, en de closure stopt Anna erin.
- Onder Octane blijft dat object in het geheugen, en elke volgende request krijgt het.
- Bram en Chloé zien de winkelmand van Anna. Dat is een datalek, en je merkt het lokaal niet, want daar test je als één user.
- Onder PHP-FPM gebeurt het niet: elke request is een nieuw proces met een nieuwe container.

> **Toelichting:** Dit soort fout duikt vaak pas op na een migratie naar Octane, in code die jaren zonder problemen draaide onder PHP-FPM. Zoek bij zo'n migratie naar singleton-bindings die auth(), request() of een user in hun closure gebruiken.

<!-- slide 52 -->

### Drie oplossingen

```php
$this->app->bind(CartService::class, fn () => new CartService(auth()->user()));

$this->app->scoped(CartService::class, fn () => new CartService(auth()->user()));

$this->app->singleton(CartService::class);

$cart->itemsFor($request->user());
```

1. bind: elke resolve een nieuwe CartService, met de user van dat moment.
2. scoped: één per request, en Octane gooit hem weg voor de volgende.
3. Of hou de singleton, maar geef de user mee aan de methode. Dat raden de Octane-docs aan.

> **Toelichting:** De Octane-docs geven dezelfde drie opties voor een singleton die de request of de container vasthoudt: geen singleton maken, een closure meegeven die telkens de huidige waarde ophaalt, of de waarde aan een methode meegeven. De derde is de eenvoudigste om te lezen en te testen.

<!-- slide 53 -->

### Regels voor processen die blijven draaien

- Geen user, request of team in de constructor van een singleton.
- State die bij één request of één job hoort: scoped.
- Geen static arrays die blijven groeien. Dat is een memory leak, want niets ruimt ze op.
- Het geldt ook voor queue workers, ook als je geen Octane gebruikt.
- [Docs: dependency injection en Octane](https://laravel.com/docs/13.x/octane#dependency-injection-and-octane)

> **Toelichting:** Een memory leak onder Octane zie je als een worker die langzaam meer geheugen gebruikt tot hij herstart. Onder PHP-FPM merk je er niets van, want het proces stopt na elke request. De les over de lifecycle legde al uit waarom een queue worker hetzelfde gedrag heeft.

<!-- slide 54 -->

### Onder Octane wil je het huidige team van de ingelogde user bijhouden in een service. Welke binding?

- A. singleton
- B. scoped
- C. instance in boot()
- D. Een static property

**Antwoord:** B. scoped

scoped geeft één object per request, en Octane gooit het weg voor de volgende. Een instance uit boot() lijkt veilig omdat boot() bij elke request draait, maar onder Octane draait boot() maar één keer per worker: het team van de eerste user blijft dan hangen, net als bij een singleton.

> **Toelichting:** Onder PHP-FPM zou elk van de eerste drie antwoorden werken, omdat de container na elke request verdwijnt. Kies toch scoped: dan werkt je code ook de dag dat iemand Octane aanzet, en het kost je niets.

<!-- slide 55 -->

### Opdracht: Exporteer facturen

In je Agency-project of een nieuw Laravel-project met een Invoice-model.

1. Maak een interface InvoiceExporter met twee implementaties: CsvExporter en JsonExporter.
2. Schrijf een `InvoiceExporterManager` die `Manager` extendt, met de default uit `config/services.php`, en registreer hem als singleton.
3. Zet er in `boot()` met `extend()` een derde driver bij die de factuur naar de log schrijft, en maak een route `/facturen/{invoice}/export/{format}`.
4. Klaar als csv, json en log werken, een onbekend formaat een 404 geeft in plaats van een 500, en een Pest-test de manager wisselt met `$this->mock()`.

Vastgelopen? Slide 29 (je eigen manager), 28 (een eigen driver), 32 (een onbekende driver) en de docs over [extend()](https://laravel.com/docs/13.x/mail#custom-transports).

> **Toelichting:** Begin met de interface en één exporter, en laat die eerst werken via een gewone controller zonder manager. Voeg daarna de manager toe: dan weet je dat een fout in de manager zit en niet in de exporter. Voor de 404 heb je twee opties, zie de toelichting bij Een onbekende driver.

## Begrippen

- **Design pattern**: Een bekende oplossing voor een probleem dat steeds terugkomt. Geen code om te kopiëren, wel een vorm met een naam, zodat "dat is een factory" genoeg uitleg is. (slide 5)
- **Gang of Four**: De vier auteurs van Design Patterns uit 1994, het boek met de 23 klassieke patterns. Factory method en singleton komen daaruit. (slide 5)
- **Chain of responsibility**: Een request gaat door een rij objecten die elk mogen afhandelen of doorgeven. Laravel's middleware werkt zo. (slide 6)
- **Observer**: Objecten die zich inschrijven op wat een ander object doet, en verwittigd worden. Events met listeners en model observers in Laravel. (slide 6)
- **Builder**: Een object dat je stap voor stap opbouwt voor het resultaat er is. De query builder, en model factories met hun states. (slide 21)
- **Simple factory**: Een class met één methode die beslist welk object je krijgt, meestal met een match. De rest van je code kent alleen de interface. (slide 12)
- **Factory method**: Het GoF-pattern waarbij een abstracte methode het object maakt en een subclass beslist welk. In Laravel zie je het in de createXDriver()-methodes van een manager. (slide 16)
- **UnhandledMatchError**: De fout van PHP als geen enkele arm van een match past. Handig in een factory: een vergeten formaat crasht meteen in plaats van stil de verkeerde class te geven. (slide 19)
- **Manager**: Een Laravel-class die drivers maakt en bijhoudt, zoals de FilesystemManager achter Storage. Hij kiest de default uit config, maakt elke driver één keer en onthoudt hem. (slide 25)
- **Driver**: Eén implementatie achter een manager: s3 of local bij Storage, postmark of smtp bij Mail, google of microsoft bij Socialite. (slide 24)
- **extend()**: De methode van een manager om er een driver bij te zetten met een closure, zoals Mail::extend('mailchimp', ...). Hoort in boot() van een service provider. (slide 28)
- **Model factory**: Een class in database/factories die modellen met nepdata bouwt voor tests en seeders. Ondanks de naam eerder een builder dan het factory pattern. (slide 20)
- **Singleton**: Een class waarvan er maar één object bestaat, met een private constructor en een static getInstance(). Klassiek een anti-pattern, in Laravel regel je het via de container. (slide 35)
- **Anti-pattern**: Een oplossing die voor de hand ligt en populair is, maar meer problemen maakt dan ze oplost. De klassieke singleton is het bekendste voorbeeld. (slide 39)
- **Global state**: Data die elke class kan lezen en aanpassen zonder dat het in een constructor staat. Een static property is global state, ook als hij private is. (slide 39)
- **Container singleton**: Een gewone class die de container maar één keer bouwt, met singleton() of #\[Singleton\]. De class zelf weet het niet, en een test kan hem wisselen. (slide 42)
- **Octane**: Een Laravel-package dat je app één keer boot en in het geheugen houdt, met FrankenPHP, Swoole of RoadRunner. Sneller, maar singletons en static state leven van request tot request. (slide 49)
- **Memory leak**: Geheugen dat blijft groeien omdat je data bijhoudt die nooit wordt opgeruimd, zoals een static array waar elke request iets aan toevoegt. Onder Octane of in een queue worker blijft dat staan. (slide 53)

## Links en bronnen

### In de presentatie

- [Eloquent: Factories](https://laravel.com/docs/13.x/eloquent-factories) (slide 21): Model factories en states
- [Illuminate\\Support\\Manager](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Support/Manager.php) (slide 25): driver(), createDriver() en extend()
- [Mail: custom transports](https://laravel.com/docs/13.x/mail#custom-transports) (slide 28, slide 55): Mail::extend() in boot()
- [Octane: dependency injection](https://laravel.com/docs/13.x/octane#dependency-injection-and-octane) (slide 53): Container, request en config in een singleton, en memory leaks

### Bronnen

- [Service Container: singleton en scoped](https://laravel.com/docs/13.x/container#binding-a-singleton): singleton(), scoped() en #\[Singleton\]
- [Mail: a specific mailer](https://laravel.com/docs/13.x/mail#sending-mail-via-a-specific-mailer): Mail::mailer('postmark')
- [Mocking](https://laravel.com/docs/13.x/mocking): $this->mock() en $this->instance()
