# Queues en jobs

Traag werk uit het request halen met jobs en workers in Laravel 13: retries, backoff, failed jobs, chains, batches en workers in productie.

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

Tags: laravel, php, queues, performance

Slides: <https://slides.mctantwerp.be/nl/the-agency/13-queues>

<!-- slide 2 -->

## Agenda

1. Waarom queues
2. Jobs
3. Workers
4. Als het misloopt
5. Twee keer hetzelfde
6. Chains en batches
7. In productie

> **Toelichting:** De hoofdstukken bouwen op elkaar: eerst waarom en hoe, dan wat er misgaat en hoe je dat opvangt, en pas op het einde productie. Heb je al jobs geschreven in een stageproject, dan kan je het eerste hoofdstuk snel doorlopen. De hoofdstukken over fouten en over twee keer hetzelfde zijn het belangrijkst: daar zit het verschil tussen een job die werkt en een job die je kan vertrouwen.

<!-- slide 3 -->

## Na deze les kan je

- Beslissen welk werk in een queue hoort en welk niet
- Een job schrijven en dispatchen, ook binnen een database-transactie
- Een worker starten, lokaal en op een server, en hem herstarten na een deploy
- Tries, backoff en timeout instellen, en een job uit failed_jobs terughalen
- Een job schrijven die twee keer mag lopen zonder dat er iets dubbel gebeurt
- Kiezen tussen een chain en een batch, en tussen de database-driver en Horizon

> **Toelichting:** Test jezelf op het einde: kan je voor elk punt een voorbeeld uit je eigen project bedenken? Zo niet, ga terug naar dat hoofdstuk. De huistaak op het einde raakt de eerste vier punten.

<!-- slide 4 -->

## 1. Waarom queues

> **Toelichting:** In bijna elk klantproject zit wel iets traags: een mail, een PDF, een koppeling met een boekhoudpakket. Zolang dat in de controller zit, bepaalt de traagste externe dienst hoe snel je app is. Queues halen dat werk uit het request, en maken je app bestand tegen diensten die even plat liggen.

<!-- slide 5 -->

### Met en zonder queue

Zonder queue: response na 4365 ms (Valideren 25 ms, Opslaan 40 ms, PDF 2100 ms, Mail via SMTP 900 ms, Boekhouding-API 1300 ms). Met queue: response na 77 ms (Valideren 25 ms, Opslaan 40 ms, Dispatch 12 ms), worker: PDF 2100 ms, Mail 900 ms, API 1300 ms.

Het werk verdwijnt niet: de PDF, de mail en de API-call gebeuren nog altijd, maar in een worker, na de response. De gebruiker wacht 77 ms in plaats van vier seconden. En ligt de boekhouding-API plat, dan faalt alleen die ene job, niet je formulier.

> **Toelichting:** De cijfers zijn realistisch: een PDF met DomPDF of Browsershot duurt al snel twee seconden, een SMTP-verbinding een seconde. Een server heeft maar een beperkt aantal PHP-processen voor requests, en elke seconde dat een request wacht, kan dat proces niemand anders helpen. Onder load maakt een queue dus ook de rest van je site sneller.

<!-- slide 6 -->

### Wat in een queue hoort

- Mails en notifications: een mailserver antwoordt niet altijd binnen de seconde
- PDF’s genereren, afbeeldingen verkleinen, video’s omzetten: rekenwerk dat seconden duurt
- Calls naar een externe API (boekhouding, CRM, Mollie). Die server is ooit traag of plat, en dan wil je niet dat je eigen request mee faalt.
- Imports en exports van duizenden rijen, die langer duren dan een request mag duren
- Webhooks: antwoord meteen met 200 en doe het werk in een job (zie [online betalingen](/nl/advanced-web-development/online-betalingen))
- Niet in een queue: alles waar de gebruiker op moet wachten om verder te kunnen, zoals een login of een validatiefout

> **Toelichting:** Een vuistregel: alles wat met een andere server praat, hoort in een queue. Twijfel je, vraag je dan af wat de gebruiker ziet als dat stuk tien seconden duurt of faalt. Moet hij het resultaat meteen zien, zoals een validatiefout, dan hoort het in het request.

<!-- slide 7 -->

### Hoe het werkt

1. Browser → Laravel, **POST /invoices**: Het formulier wordt verstuurd.
2. Laravel → Queue, **dispatch()**: Laravel schrijft de job weg: welke class, met welke argumenten. Een rij in de tabel `jobs`.
3. Laravel → Browser, **Redirect**: De response gaat meteen terug. De PDF bestaat nog niet.
4. Worker → Queue, **Volgende job?**: De worker vraagt in een lus of er werk is. Zonder worker blijft de job gewoon in de tabel staan.
5. Worker, **handle()**: De worker voert de job uit, in zijn eigen proces.
6. Worker → Queue, **Klaar: verwijderen**: Gelukt? Dan verdwijnt de job uit de queue. Mislukt? Dan gaat hij terug, of naar failed_jobs.

> **Toelichting:** De database-driver gebruikt gewone SQL: een worker reserveert een rij met een lock, zodat twee workers nooit dezelfde job pakken. Redis doet hetzelfde met atomische operaties, en is daarom sneller bij veel jobs. Voor jouw code maakt dat geen verschil: dispatch en handle blijven hetzelfde.

<!-- slide 8 -->

### Job, queue en worker

**Job**: Een class met een handle()-methode: het werk dat later moet gebeuren, samen met de data die het nodig heeft.

**Queue**: De wachtrij waar jobs in staan tot iemand ze oppikt. Een tabel, een lijst in Redis, of een dienst als Amazon SQS.

**Worker**: Een PHP-proces dat blijft draaien, jobs uit de queue haalt en ze uitvoert. Start je met `php artisan queue:work`.

> [!WARNING]
> Zonder worker blijven jobs in de queue staan en gebeurt er niets, zonder foutmelding. Dat is de fout die je het vaakst gaat maken.

> **Toelichting:** In de tabel jobs zie je per job een kolom payload (de geserialiseerde job), attempts, en available_at: het moment vanaf wanneer een worker hem mag oppikken. Een delay of een backoff is gewoon een available_at in de toekomst. Open de tabel eens in je databaseclient terwijl er geen worker draait.

<!-- slide 9 -->

### Welke taak zet je niet in een queue?

- A. De bevestigingsmail na een bestelling
- B. Het wachtwoord checken bij een login
- C. Een factuur-PDF genereren
- D. Een CSV met 20.000 klanten importeren

**Antwoord:** B. Het wachtwoord checken bij een login

De gebruiker moet weten of hij binnen is voor hij verder kan, dus daar wacht je op in het request. De CSV-import voelt misschien als iets wat je even afwacht, maar 20.000 rijen duren langer dan een request mag duren. Net die hoort in een queue, liefst in een batch.

> **Toelichting:** Een tweede twijfelgeval: een webhook van Mollie. Die verwacht snel een 200, dus je antwoordt meteen en verwerkt de betaling in een job. Bij twijfel helpt de vraag: moet de gebruiker het resultaat zien voor hij verder kan?

<!-- slide 10 -->

## 2. Jobs

> **Toelichting:** Een job is een gewone PHP-class, dus alles wat je over classes weet, geldt ook hier. Het enige speciale is dat hij geserialized wordt: wat je in de constructor meegeeft, moet naar tekst en terug kunnen. Dat verklaart de meeste vreemde fouten met jobs.

<!-- slide 11 -->

### Een job maken

```console
$ php artisan make:job GenerateInvoicePdf
```

`make:job` zet een nieuwe class in `app/Jobs`. Ze implementeert al `ShouldQueue`, dus ze gaat in de queue als je ze dispatcht. Geef een job de naam van wat hij doet, zonder Job erachter: `GenerateInvoicePdf`, `SendInvoiceMail`, `SyncInvoice`.

Meer in de docs: [Creating jobs](https://laravel.com/docs/13.x/queues#creating-jobs)

> **Toelichting:** make:job maakt standaard een queued job. Wil je werk dat altijd meteen draait, dan is een action class logischer dan een job zonder ShouldQueue (zie de les over architectuur). De naamgeving volgt de richtlijnen van Spatie: een werkwoord en een onderwerp.

<!-- slide 12 -->

### Een job

*app/Jobs/GenerateInvoicePdf.php*

```php
class GenerateInvoicePdf implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Invoice $invoice,
    ) {}

    public function handle(InvoicePdf $pdf): void
    {
        $pdf->generate($this->invoice);
    }
}
```

1. `implements ShouldQueue` zegt tegen Laravel: niet nu uitvoeren, maar in de queue zetten.
2. De trait `Queueable` regelt het serializen, en geeft je `onQueue()` en `delay()`.
3. De constructor krijgt de data die de job nodig heeft. Een model gaat mee als id, niet als kopie.
4. `handle()` draait later, in de worker. Je krijgt dependency injection zoals in een controller: `InvoicePdf` komt uit de container.

> **Toelichting:** Vroeger had een job vier traits nodig: Dispatchable, InteractsWithQueue, Queueable en SerializesModels. Sinds Laravel 11 bundelt de ene trait Queueable ze allemaal, daarom zie je in oudere code een langere lijst. Omdat handle() zijn dependencies uit de container krijgt, kan je die in een test vervangen door een fake, zie de les over fakes en mocks.

<!-- slide 13 -->

### Dispatchen

*app/Http/Controllers/InvoicesController.php*

```php
public function store(StoreInvoiceRequest $request): RedirectResponse
{
    $invoice = Invoice::create($request->validated());

    GenerateInvoicePdf::dispatch($invoice);

    return to_route('invoices.show', $invoice);
}
```

1. Eerst gewoon opslaan: de factuur moet in de database staan voor een worker ze kan ophalen.
2. `dispatch()` zet de job in de queue. De argumenten gaan naar de constructor van `GenerateInvoicePdf`.
3. De redirect vertrekt meteen. Toon op de detailpagina dus dat de PDF nog gemaakt wordt.

> **Toelichting:** Je kan ook de helper dispatch(new GenerateInvoicePdf($invoice)) gebruiken, dat doet hetzelfde. In een test gebruik je Queue::fake() om te checken dat de job gedispatcht is zonder hem uit te voeren. Toon de status van de PDF op de detailpagina, bijvoorbeeld met een kolom pdf_generated_at die de job invult.

<!-- slide 14 -->

### Meer dan dispatch

```php
GenerateInvoicePdf::dispatch($invoice)->onQueue('pdf');
SendInvoiceMail::dispatch($invoice)->delay(now()->plus(minutes: 10));
SyncInvoice::dispatchIf($invoice->isFinal(), $invoice);
GenerateInvoicePdf::dispatchSync($invoice);
```

|   | Wat het doet |
| --- | --- |
| `onQueue` | In een aparte queue, zodat een worker ze apart of met voorrang kan oppikken |
| `delay` | Pas beschikbaar na een tijd. Voor minuten, niet voor weken: een herinnering na 14 dagen is werk voor de scheduler. |
| `dispatchIf` | Alleen als de voorwaarde klopt |
| `dispatchSync` | Meteen, in het request zelf, zonder queue. Handig in een seeder of een command. |

Meer in de docs: [Dispatching jobs](https://laravel.com/docs/13.x/queues#dispatching-jobs)

> **Toelichting:** now()->plus(minutes: 10) is de schrijfwijze van Carbon 3, die de docs van Laravel 13 gebruiken. now()->addMinutes(10) werkt nog altijd en doet hetzelfde. Een delay op de database-driver is gewoon een available_at in de toekomst, op Amazon SQS is het maximum 15 minuten.

<!-- slide 15 -->

### Wat er in de queue gaat

- De class van de job en zijn constructor-argumenten, als tekst in de tabel `jobs` of in Redis
- Een model gaat mee als class en id. De worker haalt het opnieuw op, dus hij ziet de data van dat moment, niet die van bij de dispatch.
- Werd het model intussen verwijderd, dan faalt de job met een `ModelNotFoundException`. Met `#[DeleteWhenMissingModels]` verdwijnt hij dan stil.
- Geladen relaties gaan ook mee en worden volledig opnieuw geladen, ook als je ze gefilterd had. `#[WithoutRelations]` laat ze weg.
- Een closure of een UploadedFile kan niet mee. Sla het bestand eerst op een disk op en geef het pad of een model mee.

> **Toelichting:** Achter de schermen vervangt SerializesModels elk model door een ModelIdentifier met de class en het id. Dat houdt de payload klein, maar een wijziging tussen dispatch en uitvoering is daardoor zichtbaar in de job. Wil je de waarde van toen, geef dan die waarde zelf mee, bijvoorbeeld het bedrag als integer.

<!-- slide 16 -->

### Jobs en transacties

Stel: je maakt in één database-transactie een factuur en haar lijnen aan, en je dispatcht meteen de PDF-job. Een worker kan die job oppikken voor de transactie gecommit is. Hij zoekt de factuur en vindt ze niet, of vindt ze zonder lijnen.

> [!WARNING]
> Lokaal zie je dit bijna nooit, want daar is de commit sneller dan je worker. Op een drukke server wel, en dan krijg je een `ModelNotFoundException` die je niet kan reproduceren.

Meer in de docs: [Jobs and database transactions](https://laravel.com/docs/13.x/queues#jobs-and-database-transactions)

> **Toelichting:** Dit soort bug heet een race condition: het resultaat hangt af van wie eerst is, de commit of de worker. Race conditions zijn lastig omdat ze zelden optreden en zich niet laten reproduceren. Vindt een job af en toe een model niet dat er zeker is, denk dan eerst aan een transactie.

<!-- slide 17 -->

### afterCommit

```php
$invoice = DB::transaction(function () use ($request): Invoice {
    $invoice = Invoice::create($request->safe()->except('lines'));

    $invoice->lines()->createMany($request->validated('lines'));

    GenerateInvoicePdf::dispatch($invoice)->afterCommit();

    return $invoice;
});
```

`afterCommit()` houdt de job vast tot de transactie gecommit is. Rolt ze terug, dan vertrekt de job nooit. Wil je dat voor alles, zet dan `after_commit` op `true` in `config/queue.php`: dat geldt dan ook voor queued listeners, mails en notifications.

> **Toelichting:** Er is ook het omgekeerde: staat after_commit op true, dan laat beforeCommit() een enkele job toch meteen vertrekken. Een job zonder open transactie wordt altijd meteen gedispatcht, afterCommit verandert daar niets aan. Voor listeners, mailables en notifications bestaat ook de interface ShouldQueueAfterCommit, met hetzelfde effect.

<!-- slide 18 -->

### Niet alleen jobs

|   | In de queue met |
| --- | --- |
| Mail | `Mail::to($user)->queue(new InvoiceMail($invoice))`, of `ShouldQueue` op de mailable |
| Notification | `implements ShouldQueue` en `use Queueable` |
| Event listener | `implements ShouldQueue` op de listener |
| Broadcast event | `ShouldBroadcast` gaat standaard via de queue |

> [!WARNING]
> Ze hebben allemaal een worker nodig. Lokaal merk je dat niet, want `composer run dev` start er een. Draait er op de server geen, dan vertrekt er geen enkele mail.

Meer in de docs: [mail](https://laravel.com/docs/13.x/mail#queueing-mail), [notifications](https://laravel.com/docs/13.x/notifications#queueing-notifications), [listeners](https://laravel.com/docs/13.x/events#queued-event-listeners)

> **Toelichting:** Een queued mailable of notification is achter de schermen gewoon een job: SendQueuedMailable en SendQueuedNotifications. Je ziet ze dus ook in de output van queue:work en in failed_jobs. Meer over mails in de les over mails, en over broadcasting in de les over real-time.

<!-- slide 19 -->

## 3. Workers

> **Toelichting:** De code van je job is maar de helft. De andere helft is het proces dat hem uitvoert, en dat draait buiten het request, zonder browser die een fout toont. De meeste problemen met queues in productie zijn problemen met workers: er draait er geen, hij draait oude code, of hij loopt vast.

<!-- slide 20 -->

### Drivers

| Driver | Waar de jobs staan | Wanneer |
| --- | --- | --- |
| `sync` | Nergens, de job draait meteen in het request | Tests, of even debuggen |
| `database` | De tabel `jobs` | Standaard in een nieuw project |
| `redis` | In het geheugen van Redis | Veel jobs, en nodig voor Horizon |
| `sqs` | Amazon SQS | Projecten op AWS |

> [!TIP]
> Voor bijna elk Agency-project is `database` genoeg: niets extra te installeren, en je ziet de jobs gewoon staan in je databaseclient. Redis wordt pas interessant bij duizenden jobs per minuut, of als je Horizon wil. Je kiest met `QUEUE_CONNECTION` in `.env`.

> **Toelichting:** Wil je later naar Redis, dan verander je alleen QUEUE_CONNECTION: je jobs en je dispatches blijven hetzelfde. De tabel failed_jobs blijft in je database staan, ook als de queue zelf in Redis zit. De driver sync is handig in tests, maar verbergt elk probleem met serializen: test minstens één keer met een echte worker.

<!-- slide 21 -->

### Connection en queue

**Connection**: Waar de jobs staan: `database`, `redis`, `sqs`. Staat in `config/queue.php`.

**Queue (naam)**: Een aparte rij binnen één connection, zoals `mail`, `pdf` of `default`. Je maakt er een door gewoon een naam te gebruiken.

```console
$ php artisan queue:work database --queue=mail,default,pdf
```

Deze worker pakt eerst alles uit mail, dan default, en pas dan de trage PDF’s.

Meer in de docs: [Connections vs. queues](https://laravel.com/docs/13.x/queues#connections-vs-queues)

> **Toelichting:** Queues met een naam zijn handig om trage jobs te scheiden van snelle. Een import van twintig minuten op de default-queue blokkeert anders alle mails die erachter staan. Met een aparte worker per queue, of een worker met een volgorde, blijven de mails vlot vertrekken.

<!-- slide 22 -->

### De worker starten

```console
$ php artisan queue:work
  INFO  Processing jobs from the [default] queue.

  2026-09-30 14:02:11 App\Jobs\GenerateInvoicePdf ......... RUNNING
 2026-09-30 14:02:13 App\Jobs\GenerateInvoicePdf ....... 1.84s DONE
  2026-09-30 14:02:13 App\Jobs\SyncInvoice ................ RUNNING
 2026-09-30 14:02:14 App\Jobs\SyncInvoice ............ 612ms FAIL
```

Elke job verschijnt twee keer: `RUNNING` als de worker begint, `DONE` of `FAIL` als hij klaar is, met de tijd erbij. Het proces blijft draaien tot je het stopt. Maak zelf een job die `sleep(2)` doet en een die een exception gooit, en vergelijk de output.

Meer in de docs: [The queue:work command](https://laravel.com/docs/13.x/queues#the-queue-work-command)

> **Toelichting:** Met php artisan queue:work --once verwerkt de worker één job en stopt dan, handig om stap voor stap te debuggen. Met -v zie je ook het id van elke job. Een dd() in je job zie je in de terminal van de worker, niet in je browser.

<!-- slide 23 -->

### Een worker onthoudt alles

- `queue:work` start Laravel één keer en blijft dan draaien. Daarom is hij snel: hij hoeft niet voor elke job opnieuw te booten.
- Het nadeel: nieuwe code ziet hij pas na een herstart. Een fix die je deployt, draait pas als de worker opnieuw gestart is.
- Static properties en singletons blijven bestaan tussen twee jobs. Wat de ene job daar achterlaat, ziet de volgende.
- Loopt het geheugen op, gebruik dan `--max-jobs=1000` of `--max-time=3600`. De worker stopt netjes, en Supervisor start een verse.

> **Toelichting:** Hetzelfde probleem met state die blijft hangen, bestaat bij Laravel Octane voor gewone requests. De les over factory en singleton gaat daar dieper op in. Een job die iets in een static property zet, moet dat dus zelf opruimen, of beter: het niet doen.

<!-- slide 24 -->

### Na een deploy

- Zet `php artisan queue:restart` in elk deployscript. Elke worker maakt zijn huidige job af en stopt, en je procesbeheerder start hem met de nieuwe code.
- `queue:restart` zet een vlag in de cache. Is je cache driver `array`, dan komt die vlag nooit aan en herstart er niets.
- Lokaal start `composer run dev` een `queue:listen`: trager, maar met je nieuwste code bij elke job. Die draait met `--tries=1`, dus lokaal zie je geen retries tenzij je job zelf `#[Tries]` heeft.

Meer in de docs: [Queue workers and deployment](https://laravel.com/docs/13.x/queues#queue-workers-and-deployment)

> **Toelichting:** Een deployscript op Forge of in een GitHub Action eindigt typisch met de migrations, het opbouwen van de caches en queue:restart. De volgorde doet ertoe: herstart de workers pas als de nieuwe code en de migrations klaar zijn. Anders pikt een herstarte worker een job op met een database die nog niet gemigreerd is.

<!-- slide 25 -->

### Je deployt een fix in een job, maar de bug blijft. Hoe komt dat?

- A. De job staat nog in failed_jobs
- B. De worker draait nog met de oude code in zijn geheugen
- C. Redis cachet de oude job
- D. Composer moet de autoloader opnieuw maken

**Antwoord:** B. De worker draait nog met de oude code in zijn geheugen

Een worker laadt je code één keer bij het starten. Pas php artisan queue:restart laat hem stoppen, en Supervisor, Forge of Cloud start hem met de nieuwe code. De job uit failed_jobs terughalen lijkt logisch, maar zolang de worker niet herstart is, draait die retry met dezelfde oude code.

> **Toelichting:** Een worker die oude code draait, herken je aan fouten met regelnummers die niet meer kloppen, of aan een fix die in de browser werkt maar niet in de job. Check met ps aux | grep queue:work sinds wanneer je workers draaien. Draaien ze langer dan je laatste deploy, dan is queue:restart vergeten.

<!-- slide 26 -->

## 4. Als het misloopt

> **Toelichting:** Externe diensten liggen geregeld even plat, API's geven een rate limit, en soms zit er gewoon een bug in je job. De vraag is niet of een job faalt, maar wat er dan gebeurt. Met de juiste instellingen lost Laravel tijdelijke fouten zelf op, en hoor jij van de blijvende.

<!-- slide 27 -->

### Een job faalt

- Een job faalt als hij een exception gooit, of langer loopt dan zijn timeout
- Laravel telt dat als een poging en zet de job terug in de queue
- Met backoff wacht hij eerst een tijd voor de volgende poging. Zonder is hij meteen terug.
- Zijn de pogingen op, dan belandt hij in `failed_jobs` en roept Laravel `failed()` op je job op
- Stel je niets in, dan krijgt een job één poging. Eén timeout bij een externe API, en je factuur staat in failed_jobs.

> **Toelichting:** Ook een job die zichzelf met $this->release() terug in de queue zet, of een middleware als WithoutOverlapping die dat doet, verbruikt een poging. Gebruik je zulke middleware, geef de job dan meer tries. Anders belandt hij in failed_jobs zonder dat er ooit een fout was.

<!-- slide 28 -->

### Tries, backoff en timeout

```php
#[Tries(4)]
#[Backoff([10, 60, 300])]
#[Timeout(30)]
class SyncInvoice implements ShouldQueue
{
    // ...
}
```

1. `Tries(4)`: vier pogingen in totaal. Zonder dit attribuut krijgt een job er één.
2. `Backoff`: wacht 10 seconden na de eerste mislukte poging, 60 na de tweede, 300 na de derde. Daarna herhaalt de laatste waarde.
3. `Timeout(30)`: één poging mag 30 seconden duren. Langer, en de worker stopt de job en telt een mislukte poging.

Meer in de docs: [Max job attempts and timeout](https://laravel.com/docs/13.x/queues#max-job-attempts-and-timeout)

> **Toelichting:** De attributes zitten in Illuminate\Queue\Attributes. In oudere tutorials zie je public $tries = 4 en public $backoff = [...] als properties: dat is dezelfde instelling. Wil je retries op tijd in plaats van op aantal, gebruik dan een methode retryUntil() die een tijdstip teruggeeft.

<!-- slide 29 -->

### Retries en backoff

Volg `#3` van poging tot poging: hoe lang wacht hij telkens, en waar eindigt hij? Vergelijk met `#5`, die één keer faalt.

Simulatie van een queue met 2 workers en 5 jobs (GenerateInvoicePdf #1, SendInvoiceMail #2, SyncInvoice #3, GenerateInvoicePdf #4, SyncInvoice #5), met tries 4 en backoff 10, 60, 300 seconden. Elke stap toont wat de workers oppikken, welke job een exception gooit en wanneer hij terug beschikbaar is, tot een job zonder pogingen in failed_jobs belandt.

> **Toelichting:** De simulatie volgt de regels van Laravel: een worker pakt de oudste job die beschikbaar is, en een job die faalt, sluit achteraan aan. In je eigen project zie je hetzelfde in de tabel jobs: kijk naar attempts en available_at terwijl een job faalt. Speel met de backoff en kijk hoe available_at verschuift.

<!-- slide 30 -->

### Wat de simulatie toont

- De boekhouding-API ligt plat voor #3, dus elke poging faalt. De wachttijd groeit: 10, dan 60, dan 300 seconden.
- Na de vierde poging is het op: #3 staat in failed_jobs, en failed() draait. Zonder backoff was dat binnen de seconde gebeurd.
- #5 faalt één keer en lukt bij de tweede poging. Dat is het geval waarvoor retries bestaan: een fout die vanzelf overgaat.
- Een gefaalde job sluit achteraan de rij aan. De andere jobs wachten niet op hem.

> **Toelichting:** Een backoff die telkens groter wordt, is de standaard bij API's. Zo geef je een dienst die overbelast is de tijd om te herstellen, in plaats van hem te bestoken met retries. Sommige API's sturen bij een 429 een header Retry-After mee: die waarde kan je in je job gebruiken met $this->release().

<!-- slide 31 -->

### Opruimen na een fout

*app/Jobs/SyncInvoice.php*

```php
public function failed(?Throwable $exception): void
{
    $this->invoice->update(['sync_failed_at' => now()]);

    $this->invoice->owner->notify(new InvoiceSyncFailed($this->invoice));
}
```

`failed()` is de plek om een status te zetten, zodat iemand in je app op Opnieuw kan klikken, of om de gebruiker te verwittigen. Zet er geen werk in dat moet lukken: ook `failed()` kan crashen.

> [!NOTE]
> `failed()` draait op een nieuwe instantie van de job. Wat je in `handle()` in een property zette, is weg.

> **Toelichting:** Het argument van failed() is de exception van de laatste poging. Waren de pogingen op, dan is het een MaxAttemptsExceededException, bij een timeout een TimeoutExceededException. Log die exception, zodat je later kan zien waarom de job faalde.

<!-- slide 32 -->

### Timeout en retry_after

- `timeout` zegt hoe lang één poging mag duren. Daarna stopt de worker de job, en zichzelf.
- `retry_after` in `config/queue.php` zegt na hoeveel seconden een job die nog loopt terug vrijkomt, voor het geval de worker gecrasht is
- Stel: een export met #\[Timeout(300)\] en retry_after op 90. Na 90 seconden pakt een tweede worker dezelfde job op: twee exports, twee mails naar de klant.
- Zet je timeout dus altijd een paar seconden korter dan retry_after (standaard 90)
- Een HTTP-call respecteert de timeout van de worker niet altijd. Geef hem er zelf een: `Http::timeout(10)`.

> **Toelichting:** De timeout gebruikt de PHP-extensie pcntl, die je op een Linux-server bijna altijd hebt, maar op Windows niet. Zonder pcntl stopt een job die vastloopt nooit. retry_after is ook het vangnet voor een worker die crasht: zonder die instelling zou een job van een gecrashte worker voor altijd gereserveerd blijven.

<!-- slide 33 -->

### SyncInvoice heeft #\[Tries(4)\] en #\[Backoff(\[10, 60, 300\])\], en de API ligt plat. Hoe lang na de eerste poging belandt hij in failed_jobs?

- A. Ongeveer 370 seconden
- B. Ongeveer 300 seconden
- C. Na 10 seconden
- D. Nooit, hij blijft proberen

**Antwoord:** A. Ongeveer 370 seconden

Vier pogingen betekent drie keer wachten: 10 + 60 + 300 = 370 seconden, plus de tijd van de pogingen zelf. 300 is de verleiding als je alleen naar de laatste waarde kijkt, maar de wachttijden tellen op. En hij blijft niet proberen: na de vierde poging stopt Laravel.

> **Toelichting:** Wil je dat een job lang blijft proberen zonder een groot aantal tries te kiezen, gebruik dan retryUntil(), bijvoorbeeld now()->plus(hours: 2). Voor een API die een halve dag plat kan liggen, is dat vaak logischer dan tellen. Met --tries=0 op de worker probeert een job zonder eigen instelling eindeloos, en dat wil je bijna nooit.

<!-- slide 34 -->

### Failed jobs

- `failed_jobs` bewaart de job, de exception en de stack trace. `queue:failed` toont ze met hun id.
- Fix de bug, deploy, en zet de job terug met `queue:retry` en zijn id. Hij draait opnieuw met de data van nu.
- Lag een externe API een uur plat? `queue:retry all` zet alles terug.
- `queue:prune-failed` ruimt oude op. Plan hem dagelijks (zie de les over scheduling).

```console
$ php artisan queue:failed
$ php artisan queue:retry ce7bb17c-cdd8
$ php artisan queue:retry all
$ php artisan queue:forget ce7bb17c-cdd8
$ php artisan queue:prune-failed --hours=48
```

Meer in de docs: [Retrying failed jobs](https://laravel.com/docs/13.x/queues#retrying-failed-jobs)

> **Toelichting:** queue:retry zet de job terug met zijn oorspronkelijke payload. Werd het model intussen verwijderd, dan faalt de retry opnieuw met een ModelNotFoundException. Gebruik je Horizon, dan retry je failed jobs ook met een knop in het dashboard.

<!-- slide 35 -->

### Tijdelijk of blijvend

#### Tijdelijk: retry helpt

- Een timeout
- Een 503 of 429 van een API
- Een deadlock in de database
- Backoff geeft de andere kant tijd om te herstellen

#### Blijvend: retry helpt niet

- Een klant zonder btw-nummer
- Een 422 van de API: je stuurt foute data
- Een factuur die intussen verwijderd is
- Wordt niet beter na 370 seconden: faal meteen

> **Toelichting:** Een Http-call met ->throw() gooit een RequestException die je kan inspecteren: $exception->response->status() geeft je de statuscode. Zo beslis je in je job of een fout tijdelijk is (5xx, 429) of blijvend (4xx). De les over API's gaat dieper in op retries bij HTTP-calls.

<!-- slide 36 -->

### Meteen falen

```php
public function handle(Accounting $accounting): void
{
    if (! $this->invoice->customer->vat_number) {
        $this->fail('Klant zonder btw-nummer, een retry helpt niet.');

        return;
    }

    $accounting->push($this->invoice);
}
```

`$this->fail()` zet de job meteen in `failed_jobs`, zonder de overige pogingen. Vergeet de `return` niet: `fail()` stopt je code niet. Voor een hele soort fout gebruik je `$exceptions->dontRetry([...])` in `bootstrap/app.php`.

> **Toelichting:** Er is ook de job middleware FailOnException: je geeft er een lijst exceptions aan, en bij die exceptions faalt de job meteen in plaats van opnieuw te proberen. Handig voor een AuthorizationException: een gebruiker die zijn rechten kwijt is, krijgt ze niet terug na 60 seconden.

<!-- slide 37 -->

## 5. Twee keer hetzelfde

> **Toelichting:** Queues beloven at-least-once: een job loopt minstens één keer, soms vaker. Exactly-once bestaat in de praktijk bij geen enkele queue. Je code moet daar dus tegen kunnen, zeker als er geld, mails of externe systemen aan hangen.

<!-- slide 38 -->

**Elke job kan twee keer lopen.**

Een worker die crasht vlak na het werk, een timeout, een retry, een dubbele klik op Verstuur. Reken erop.

> **Toelichting:** Een dubbele klik is de meest voorkomende oorzaak: twee requests, twee dispatches, twee jobs. Een knop die na de eerste klik uitgeschakeld wordt, helpt, maar is geen garantie: de server moet het zelf opvangen. Denk bij elke job na wat er gebeurt als hij twee keer loopt.

<!-- slide 39 -->

### Idempotent

Idempotent betekent: twee keer uitvoeren geeft hetzelfde resultaat als één keer. Stel: een job geeft een klant 100 credits na een betaling. De worker crasht net na het werk, voor hij de job kan afvinken. Na `retry_after` pikt een andere worker dezelfde job op, en de klant heeft er 200.

> [!TIP]
> Een idempotente job checkt eerst of het werk al gedaan is, en onthoudt dat hij het deed.

> **Toelichting:** Het woord komt ook in HTTP voor: GET, PUT en DELETE zijn idempotent, POST niet. Een PUT die twee keer hetzelfde adres opslaat, laat hetzelfde resultaat achter. Schrijf je jobs liever zo dat ze een toestand zetten (dit is betaald) dan dat ze een verschil optellen (tel er 100 bij).

<!-- slide 40 -->

### Twee keer is één keer

#### Niet idempotent

```php
public function handle(): void
{
    $this->order->customer->addCredits(100);
}
```

#### Idempotent

```php
public function handle(): void
{
    if ($this->order->credited_at) {
        return;
    }

    $this->order->customer->addCredits(100);

    $this->order->update(['credited_at' => now()]);
}
```

> **Toelichting:** Nog sterker is een unique constraint in de database, bijvoorbeeld een tabel credit_grants met een unieke order_id. Dan houdt de database een tweede insert tegen, ook als twee workers tegelijk starten. Een check in PHP en een constraint in de database vullen elkaar aan.

<!-- slide 41 -->

### Nog niet waterdicht

De versie met `credited_at` houdt een retry tegen. Maar starten twee workers exact tegelijk, dan zien ze allebei nog geen `credited_at`. Daarvoor heb je `WithoutOverlapping` of een lock nodig.

> [!NOTE]
> Praat je job met een externe API, vraag dan naar een idempotency key. Mollie en Stripe aanvaarden een header waarmee een tweede call met dezelfde key geen tweede betaling maakt.

> **Toelichting:** Een lock kan je ook zelf nemen met Cache::lock() en een naam per order. De job middleware WithoutOverlapping doet dat voor je, voor de job als geheel. Een idempotency key is meestal een uuid die je bij je order bewaart en bij elke call naar de API meestuurt.

<!-- slide 42 -->

### Twintig keer dezelfde sync

Stel: bij elke wijziging aan een product dispatch je een sync naar de webshop. Iemand past twintig velden aan, en er staan twintig syncs voor hetzelfde product in de queue. Negentien daarvan zijn zinloos.

Een unique job lost dat op: zolang er een sync voor dat product wacht of loopt, negeert Laravel de volgende dispatches. Dat werkt met een lock in de cache, dus al je servers moeten dezelfde cache gebruiken.

Meer in de docs: [Unique jobs](https://laravel.com/docs/13.x/queues#unique-jobs)

> **Toelichting:** Unique jobs en debounced jobs lijken op elkaar. Unique houdt de eerste en negeert de rest, debounce laat alleen de laatste lopen. Voor een sync van een product wil je meestal de laatste versie, en dan is #[DebounceFor] de betere keuze.

<!-- slide 43 -->

### Unique jobs

```php
#[UniqueFor(3600)]
class SyncInvoice implements ShouldQueue, ShouldBeUnique
{
    public function __construct(public Invoice $invoice) {}

    public function uniqueId(): string
    {
        return (string) $this->invoice->id;
    }
}
```

1. `ShouldBeUnique`: zolang deze job wacht of loopt, wordt een nieuwe dispatch van dezelfde job genegeerd.
2. `uniqueId()` bepaalt wat dezelfde is: één per factuur. Factuur 12 houdt die van 13 niet tegen.
3. `UniqueFor(3600)`: na een uur vervalt de lock, ook als de job nog niet klaar is.

> **Toelichting:** Unique jobs werken niet binnen een batch: daar negeert Laravel de lock. Met een methode uniqueVia() kies je een andere cache store voor de lock, bijvoorbeeld Redis als je default cache de database is. Wil je dat de lock vrijkomt zodra de job start, gebruik dan ShouldBeUniqueUntilProcessing.

<!-- slide 44 -->

### Welke heb je nodig

|   | Wat het tegenhoudt |
| --- | --- |
| `ShouldBeUnique` | Dezelfde job opnieuw in de queue zolang de vorige nog wacht of loopt |
| `ShouldBeUniqueUntilProcessing` | Hetzelfde, maar de lock gaat eraf zodra de job start |
| `WithoutOverlapping` | Twee jobs met dezelfde key die tegelijk lopen. De tweede gaat terug in de queue. |
| `#[DebounceFor(30)]` | Tien dispatches binnen 30 seconden: alleen de laatste draait |

```php
public function middleware(): array
{
    return [new WithoutOverlapping($this->invoice->customer_id)];
}
```

`WithoutOverlapping` is job middleware: hier nooit twee jobs tegelijk voor dezelfde klant.

> **Toelichting:** Het verschil: unique kijkt bij het dispatchen, WithoutOverlapping bij het uitvoeren. Een job die WithoutOverlapping tegenkomt, gaat terug in de queue en verbruikt een poging, dus geef hem genoeg tries, of gebruik releaseAfter() of dontRelease(). Job middleware werkt ook op queued listeners, mailables en notifications.

<!-- slide 45 -->

### Een gebruiker klikt vijf keer op 'Sync nu'. Wat zorgt ervoor dat er maar één sync in de queue staat?

- A. #\[Tries(1)\]
- B. ShouldBeUnique met een uniqueId
- C. afterCommit()
- D. Een hogere retry_after

**Antwoord:** B. ShouldBeUnique met een uniqueId

ShouldBeUnique neemt een lock in de cache per uniqueId. Zolang die job wacht of loopt, worden nieuwe dispatches genegeerd. #\[Tries(1)\] is de verleiding, maar dat beperkt de pogingen van één job. Vijf aparte dispatches houdt het niet tegen: je krijgt nog altijd vijf syncs.

> **Toelichting:** Zet de knop in je interface na een klik ook even op uitgeschakeld, zodat de gebruiker ziet dat er iets gebeurt. Maar de echte bescherming zit op de server: een tweede tabblad of een dubbele request door een trage verbinding omzeilt je knop. ShouldBeUnique vangt ze allemaal.

<!-- slide 46 -->

## 6. Chains en batches

> **Toelichting:** Zodra een taak uit meerdere stappen bestaat, of uit heel veel gelijke stukken, wordt één grote job lastig: hij duurt lang, en als hij halverwege faalt, begint hij opnieuw van nul. Chains en batches knippen het werk op. Elk stuk krijgt zijn eigen retries.

<!-- slide 47 -->

### Chain of batch

#### Chain

- Jobs na elkaar, in een vaste volgorde
- Voor stappen die op elkaar wachten: de mail heeft de PDF nodig
- Faalt er één, dan stopt de rest

#### Batch

- Jobs naast elkaar, verdeeld over al je workers
- Voor veel gelijk werk: een import in stukken van 500 rijen
- Callbacks als alles klaar is of er één faalt, en een voortgang

> **Toelichting:** Je kan ze combineren: een batch met chains erin (per klant eerst de PDF, dan de mail), of een chain met batches (eerst alle PDF's, dan alle mails). Begin met de eenvoudigste vorm die werkt. Een chain van drie jobs is vaak al genoeg.

<!-- slide 48 -->

### Chain

```php
Bus::chain([
    new GenerateInvoicePdf($invoice),
    new SendInvoiceMail($invoice),
    new SyncInvoice($invoice),
])->catch(function (Throwable $exception) use ($invoice) {
    $invoice->update(['status' => InvoiceStatus::Stuck]);
})->dispatch();
```

1. De jobs lopen na elkaar, in die volgorde. De mail start pas als de PDF er is, want die gaat als bijlage mee.
2. Faalt er één, dan starten de volgende niet en draait `catch()`. Gebruik daarin geen `$this`: de closure wordt geserialized en later uitgevoerd.

Meer in de docs: [Job chaining](https://laravel.com/docs/13.x/queues#job-chaining)

> **Toelichting:** Binnen een job kan je de chain nog aanpassen met prependToChain() en appendToChain(). Let op: een job die zichzelf verwijdert met $this->delete(), stopt de chain niet. Alleen een job die faalt, stopt de rest.

<!-- slide 49 -->

### Batch

```php
$batch = Bus::batch([
    new ImportCustomers($import, fromRow: 1, toRow: 500),
    new ImportCustomers($import, fromRow: 501, toRow: 1000),
    new ImportCustomers($import, fromRow: 1001, toRow: 1500),
])->then(function (Batch $batch) use ($import) {
    $import->markFinished();
})->catch(function (Batch $batch, Throwable $exception) use ($import) {
    $import->markFailed($exception->getMessage());
})->name('Klanten importeren')->dispatch();
```

1. Drie jobs die elk 500 rijen importeren, naast elkaar.
2. `then()` draait als alle jobs gelukt zijn.
3. `catch()` draait bij de eerste job die faalt.
4. `name()` maakt de batch herkenbaar in Horizon, `dispatch()` start hem.

> **Toelichting:** Er zijn ook before(), progress() en finally(): progress() draait na elke job die lukt, finally() altijd op het einde. Faalt er een job, dan markeert Laravel de batch standaard als geannuleerd. Met allowFailures() loopt hij gewoon door.

<!-- slide 50 -->

### Een batch opzetten

- Elke job in een batch heeft de trait `Batchable` nodig
- Laravel houdt de voortgang bij in de tabel `job_batches`. Die maak je met `php artisan make:queue-batches-table`.
- Check in je job `$this->batch()->cancelled()`, zodat hij stopt als iemand de batch annuleert
- `Bus::findBatch($id)->progress()` geeft 0 tot 100, genoeg voor een voortgangsbalk op de pagina van de import

Meer in de docs: [Job batching](https://laravel.com/docs/13.x/queues#job-batching)

> **Toelichting:** De tabel job_batches groeit met elke batch. Plan php artisan queue:prune-batches dagelijks om oude batches op te ruimen, zie de les over scheduling. Geef je een batch terug uit een route, dan krijg je JSON met de voortgang, handig om met fetch elke paar seconden een balk bij te werken.

<!-- slide 51 -->

## 7. In productie

> **Toelichting:** Lokaal start composer run dev alles voor je. Op een server start niemand iets vanzelf: jij beslist wie de workers draait, hoe ze herstarten en hoe je weet dat ze leven. Die vragen horen in elke overdracht aan een klant.

<!-- slide 52 -->

### Supervisor

Op de server moet iemand `queue:work` starten, en opnieuw starten als hij stopt: na een crash, na `queue:restart`, na `--max-time`. Op een eigen Linux-server doet Supervisor dat. Je installeert het met `apt-get install supervisor` en zet per project een config in `/etc/supervisor/conf.d`.

```console
$ sudo supervisorctl reread
$ sudo supervisorctl update
$ sudo supervisorctl start "agency-worker:*"
```

Meer in de docs: [Supervisor configuration](https://laravel.com/docs/13.x/queues#supervisor-configuration)

> **Toelichting:** Supervisor is niet specifiek voor Laravel: het is een algemeen programma dat processen bewaakt. Een alternatief is systemd, dat op elke moderne Linux-server zit. Op Forge en Laravel Cloud regelt het platform dit voor je, en zie je er zelf niets van.

<!-- slide 53 -->

### De config

```ini
[program:agency-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/agency.be/artisan queue:work --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=2
user=forge
stdout_logfile=/home/forge/agency.be/storage/logs/worker.log
stopwaitsecs=3600
```

1. De worker zelf: drie pogingen per job, en na een uur een verse start. numprocs=2 draait er twee naast elkaar.
2. Supervisor start de workers als de server opstart, en opnieuw als ze stoppen.
3. stopwaitsecs moet langer zijn dan je langste job, anders kilt Supervisor hem halverwege.

> **Toelichting:** %(program_name)s_%(process_num)02d geeft elke worker een eigen naam, zoals agency-worker_00 en agency-worker_01. Met numprocs verhoog je het aantal workers als jobs te lang wachten. Het logbestand in storage/logs helpt als een worker telkens opnieuw crasht.

<!-- slide 54 -->

### Forge, Cloud of zelf

- **[Eigen server](https://supervisord.org)**: Supervisor zelf instellen, en `queue:restart` in je deployscript.
- **[Laravel Forge](https://laravel.com/forge/docs/sites/queues)**: Workers aanmaken in het dashboard van je site. Forge zet Supervisor op. `queue:restart` hoort in je deployscript.
- **[Laravel Cloud](https://laravel.com/cloud/docs/queues)**: Managed queues: Cloud draait de workers en schaalt mee met het werk. Of een worker cluster met je eigen `queue:work`.

Wat je ook kiest: zet in de overdracht aan de klant waar de workers draaien en wie ze herstart. Een project zonder werkende worker lijkt maanden in orde, tot iemand merkt dat er geen mails meer vertrekken.

> **Toelichting:** Op Forge voeg je queue:restart toe aan je deployscript, of je gebruikt zero downtime deployments, waar de macro $RESTART_QUEUES() dat doet. Lees bij elk platform na hoe het workers herstart na een deploy: dat verschilt, en het is precies wat je vergeet.

<!-- slide 55 -->

### Horizon

- Een dashboard op `/horizon`: wat draait, wat faalt, hoe lang jobs wachten
- De workers staan in code, per omgeving, in `config/horizon.php`
- Werkt alleen met de redis-driver
- In productie beslist de gate `viewHorizon` wie het dashboard ziet
- Na een deploy gebruik je `horizon:terminate` in plaats van `queue:restart`

```console
$ composer require laravel/horizon
$ php artisan horizon:install
$ php artisan horizon
```

Meer in de docs: [Laravel Horizon](https://laravel.com/docs/13.x/horizon)

> **Toelichting:** Horizon verdeelt zelf het aantal workers over je queues: staan er veel jobs in de mail-queue, dan schuift het workers daarheen. Met tags vind je in het dashboard alle jobs van één klant of één factuur terug. Voor de grafieken met metrics plan je horizon:snapshot elke vijf minuten.

<!-- slide 56 -->

### Heb je Horizon nodig

- **De database-driver volstaat**: Een klantproject met een paar honderd mails en PDF’s per dag. Je ziet de jobs in de tabel, failed jobs in `failed_jobs`, en je hoeft geen Redis te beheren.
- **Tijd voor Horizon**: Duizenden jobs per uur, meerdere queues met voorrang, of jobs die blijven hangen en je wil zien waar. Dan is Horizon het eerste wat je installeert.

Voor de meeste Agency-projecten is Horizon overkill. Begin met de database-driver, en stap over als de klant echt volume heeft.

> **Toelichting:** Telescope en Pulse tonen ook jobs, en werken met elke driver. Pulse geeft een overzicht van trage jobs en drukke queues in productie, Telescope is eerder voor lokaal debuggen. Ze kunnen een tussenstap zijn voor je naar Redis en Horizon overstapt.

<!-- slide 57 -->

### Weten dat het misloopt

- failed_jobs groeit in stilte. Stel: een SyncInvoice faalt zonder melding. De factuur komt nooit in de boekhouding, en dat merkt iemand pas bij de btw-aangifte.
- `queue:monitor database:default --max=100`, elke minuut gepland, meldt een queue die volloopt via het event `QueueBusy`
- Exceptions uit jobs horen in je error tracker, net als die uit requests. Kies er één per project: Flare, Sentry of Nightwatch.
- Horizon en Laravel Cloud tonen wachttijden en failed jobs in hun dashboard

Meer in de docs: [Monitoring your queues](https://laravel.com/docs/13.x/queues#monitoring-your-queues)

> **Toelichting:** Laravel dispatcht bij elke gefaalde job het event JobFailed. Een listener daarop kan je een melding in Slack sturen. Hou het wel beperkt: een melding per gefaalde job wordt snel ruis als een API een uur plat ligt.

<!-- slide 58 -->

### Opdracht: Facturen in de queue

Huistaak, in je Agency-project of in een nieuw Laravel-project met de database-driver. Vast? Kijk naar de slides Dispatchen, Tries, backoff en timeout en Failed jobs, of naar de [docs over queues](https://laravel.com/docs/13.x/queues).

1. Maak een job die iets traags doet (een PDF, een API-call, of sleep(3)) en dispatch hem vanuit een controller
2. Laat de eerste twee pogingen falen met `if ($this->attempts() < 3) { throw ... }`, en geef de job `#[Tries(3)]` en `#[Backoff([5, 15])]`
3. Zet de tries op 2, laat de job in failed_jobs belanden en haal hem terug met queue:retry
4. Klaar als de response onder de 200 ms blijft, de output van queue:work je pogingen toont, en een job die twee keer loopt maar één keer iets doet

> **Toelichting:** Begin met de job zonder fouten: dispatch hem, start queue:work en kijk of hij DONE toont. Voeg daarna pas de exception toe. Kijk tijdens het falen in de tabel jobs naar attempts en available_at, dan zie je de backoff gebeuren.

## Begrippen

- **ShouldQueue**: De interface die van een class een queued job maakt. Ook op een mailable, notification of listener betekent hij hetzelfde: niet nu uitvoeren, maar in de queue zetten. (slide 12)
- **Dispatch**: Een job in de queue zetten, met GenerateInvoicePdf::dispatch($invoice). De argumenten gaan naar de constructor van de job, en je request gaat meteen verder. (slide 13)
- **Queue driver**: Waar de jobs bewaard worden: database (de tabel jobs, standaard), redis, sqs, of sync, dat de job meteen in het request uitvoert. Je kiest met QUEUE_CONNECTION in .env. (slide 20)
- **Model serialization**: Een Eloquent-model in een job gaat mee als class en id. De worker haalt het opnieuw op uit de database, dus hij ziet de data van dat moment, niet die van bij de dispatch. (slide 15)
- **afterCommit**: Wacht met dispatchen tot de database-transactie gecommit is. Zonder kan een worker een job oppikken voor de data bestaat. Kan per dispatch, of voor alles met after_commit in config/queue.php. (slide 17)
- **queue:restart**: Laat elke worker zijn huidige job afmaken en stoppen, zodat Supervisor, Forge of Cloud hem met de nieuwe code opnieuw start. Hoort in elk deployscript. (slide 24)
- **Tries**: Hoeveel pogingen een job krijgt, met #\[Tries(4)\] op de class of --tries op de worker. Zonder instelling is dat één poging. (slide 28)
- **Backoff**: Hoeveel seconden een gefaalde job wacht voor de volgende poging. #\[Backoff(\[10, 60, 300\])\] wacht 10, dan 60, dan 300 seconden, en herhaalt de laatste waarde. (slide 28)
- **Timeout**: Hoe lang één poging mag duren, standaard 60 seconden. Daarna stopt de worker de job en zichzelf, en telt het als een mislukte poging. (slide 32)
- **retry_after**: De instelling in config/queue.php die zegt na hoeveel seconden een job die nog bezig is, terug vrijkomt. Moet altijd hoger zijn dan je timeout, anders loopt een job twee keer tegelijk. (slide 32)
- **failed_jobs**: De tabel waar een job belandt als zijn pogingen op zijn, met de exception erbij. Je bekijkt ze met queue:failed en zet ze terug met queue:retry. (slide 34)
- **failed()**: Een methode op je job die Laravel oproept als de job definitief faalt. De plek om een status terug te zetten of iemand te verwittigen. Hij draait op een nieuwe instantie van de job. (slide 31)
- **Idempotent**: Twee keer uitvoeren geeft hetzelfde resultaat als één keer. Een job kan altijd twee keer lopen, dus hij checkt eerst of het werk al gedaan is. (slide 39)
- **ShouldBeUnique**: Een job met deze interface wordt niet opnieuw gedispatcht zolang dezelfde job (zelfde uniqueId) nog wacht of loopt. Werkt met een lock in de cache. (slide 43)
- **WithoutOverlapping**: Job middleware die zorgt dat nooit twee jobs met dezelfde key tegelijk lopen. De tweede gaat terug in de queue en telt een poging. (slide 44)
- **Chain**: Jobs die na elkaar lopen, met Bus::chain(). Faalt er één, dan stopt de rest. Voor stappen waar de volgorde telt. (slide 48)
- **Batch**: Jobs die naast elkaar lopen, met Bus::batch(), en callbacks als alles klaar is of er één faalt. Met progress() toon je een voortgangsbalk. Heeft de tabel job_batches nodig. (slide 49)
- **Supervisor**: Een procesbeheerder voor Linux die je queue:work-processen laat draaien en herstart als ze stoppen. Forge zet hem voor je op. (slide 52)
- **Horizon**: Het dashboard en de workerconfiguratie van Laravel voor Redis-queues. Toont wat draait, wat faalt en hoe lang jobs wachten. Na een deploy: horizon:terminate. (slide 55)
- **Managed queues**: De queues van Laravel Cloud: Cloud draait de workers en schaalt ze mee met het werk, jij hoeft geen queue:work te beheren. (slide 54)

## Links en bronnen

### In de presentatie

- [Creating jobs](https://laravel.com/docs/13.x/queues#creating-jobs) (slide 11)
- [Dispatching jobs](https://laravel.com/docs/13.x/queues#dispatching-jobs) (slide 14)
- [Jobs and database transactions](https://laravel.com/docs/13.x/queues#jobs-and-database-transactions) (slide 16)
- [Queueing mail](https://laravel.com/docs/13.x/mail#queueing-mail) (slide 18): Mail::to()->queue() en ShouldQueue op een mailable
- [Queueing notifications](https://laravel.com/docs/13.x/notifications#queueing-notifications) (slide 18): ShouldQueue en Queueable op een notification
- [Queued event listeners](https://laravel.com/docs/13.x/events#queued-event-listeners) (slide 18): ShouldQueue op een listener
- [Connections vs. queues](https://laravel.com/docs/13.x/queues#connections-vs-queues) (slide 21)
- [The queue:work command](https://laravel.com/docs/13.x/queues#the-queue-work-command) (slide 22)
- [Queue workers and deployment](https://laravel.com/docs/13.x/queues#queue-workers-and-deployment) (slide 24)
- [Max job attempts and timeout](https://laravel.com/docs/13.x/queues#max-job-attempts-and-timeout) (slide 28)
- [Retrying failed jobs](https://laravel.com/docs/13.x/queues#retrying-failed-jobs) (slide 34)
- [Unique jobs](https://laravel.com/docs/13.x/queues#unique-jobs) (slide 42)
- [Job chaining](https://laravel.com/docs/13.x/queues#job-chaining) (slide 48)
- [Job batching](https://laravel.com/docs/13.x/queues#job-batching) (slide 50)
- [Supervisor configuration](https://laravel.com/docs/13.x/queues#supervisor-configuration) (slide 52)
- [Supervisor](https://supervisord.org) (slide 54): Procesbeheer voor queue:work op een eigen server
- [Forge: queues](https://laravel.com/forge/docs/sites/queues) (slide 54): Workers in het dashboard, queue:restart in het deployscript
- [Laravel Cloud: managed queues](https://laravel.com/cloud/docs/queues) (slide 54): Managed queues tegenover eigen workers
- [Laravel Horizon](https://laravel.com/docs/13.x/horizon) (slide 55): Installatie, dashboard, horizon:terminate bij een deploy
- [Monitoring your queues](https://laravel.com/docs/13.x/queues#monitoring-your-queues) (slide 57)
- [Queues](https://laravel.com/docs/13.x/queues) (slide 58): Laravel 13: jobs, attributes als Tries en Backoff, workers, failed jobs, batches
