# Scheduling en cronjobs

Cron-expressies lezen en schrijven, de Laravel scheduler in routes/console.php, en taken die op de server echt draaien en je laten weten als ze dat niet doen.

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

Tags: laravel, php, scheduling, queues

Slides: <https://slides.mctantwerp.be/nl/the-agency/14-scheduling>

<!-- slide 2 -->

## Agenda

1. Cron
2. De scheduler
3. Frequenties en voorwaarden
4. Als taken botsen
5. Op de server
6. Monitoring

> **Toelichting:** Het eerste hoofdstuk gaat over cron zelf, los van Laravel. Die kennis heb je ook buiten Laravel nodig: in GitHub Actions, in Kubernetes, op een Raspberry Pi. Daarna gaat het over de scheduler van Laravel, en op het einde over wat er op de server moet gebeuren.

<!-- slide 3 -->

## Na deze les kan je

- Een cron-expressie lezen en schrijven, met lijsten, bereiken en stappen
- Taken plannen in routes/console.php, met de juiste frequentie en voorwaarden
- Kiezen tussen een command, een job of een closure voor een geplande taak
- Overlap, meerdere servers en het zomeruur opvangen
- De scheduler laten draaien op je eigen server, op Forge of op Laravel Cloud
- Een geplande taak bewaken met hooks, heartbeats of een schedule monitor

> **Toelichting:** De laatste twee punten worden het vaakst vergeten in echte projecten: de cronjob op de server en de monitoring. Een planning die perfect geschreven is maar nooit start, doet niets. Check na de les of je voor je eigen project weet waar de scheduler draait.

<!-- slide 4 -->

## 1. Cron

> **Toelichting:** Cron bestaat sinds de jaren 70 en draait op zo goed als elke Linux- en macOS-machine. De syntax van cron-expressies wordt overal gebruikt, ook in tools die niets met Linux te maken hebben. Wie hem één keer echt snapt, leest elke planning.

<!-- slide 5 -->

### Werk dat niemand start

- Elke nacht een backup van de database
- Om 9 uur een herinnering voor facturen die 14 dagen over tijd zijn
- Elke vijf minuten reservaties opruimen die nooit betaald werden
- Elk uur de voorraad syncen met het ERP van de klant
- Op de 1e van de maand de abonnementen verlengen

Bij geen van deze taken klikt een gebruiker ergens, er is geen request. Iets moet op een vast moment wakker worden en het werk starten. Op een Linux-server is dat cron.

> **Toelichting:** Sommige van die taken kunnen ook met een delayed job, maar niet allemaal. Een herinnering 14 dagen na de factuurdatum kan je in theorie als delay in de queue zetten, maar wijzigt de factuur intussen, dan klopt die job niet meer. Een taak die elke ochtend kijkt welke facturen nu over tijd zijn, is robuuster.

<!-- slide 6 -->

### Cron

**Cron**: Een proces op elke Linux-server dat elke minuut kijkt of er iets moet draaien. Heet zo naar chronos, Grieks voor tijd.

**Crontab**: De lijst met wat cron moet doen, per gebruiker. Je past hem aan met `crontab -e`.

**Cronjob**: Eén regel in die lijst: wanneer, en welk commando.

Meer in de docs: [crontab(5), de man page](https://man7.org/linux/man-pages/man5/crontab.5.html)

> **Toelichting:** Op macOS draait cron ook, maar Apple raadt launchd aan. Op Windows heet het Task Scheduler. Voor Laravel hoef je lokaal geen van beide in te stellen: schedule:work doet het werk.

<!-- slide 7 -->

### Een regel in de crontab

```bash
30 2 * * *  cd /home/forge/agency.be && php artisan backup:run
```

| Veld | Waarden | Hier |
| --- | --- | --- |
| Minuut | 0 tot 59 | `30` |
| Uur | 0 tot 23 | `2` |
| Dag van de maand | 1 tot 31 | `*` |
| Maand | 1 tot 12, of jan tot dec | `*` |
| Weekdag | 0 tot 7, of sun tot sat. 0 en 7 zijn allebei zondag. | `*` |

Eerst vijf velden, dan het commando. Een ster betekent: maakt niet uit. Deze regel draait dus elke dag om 02:30.

> **Toelichting:** De tijd van cron is die van de server, en dat is vaak UTC. Een regel met 2 in het uurveld draait dan om 3 of 4 uur Belgische tijd, afhankelijk van het seizoen. Check de tijdzone van je server met het commando date.

<!-- slide 8 -->

### Vier tekens

| Teken | Betekent | Voorbeeld | Draait |
| --- | --- | --- | --- |
| `*` | Elke waarde | `* * * * *` | Elke minuut |
| `,` | Een lijst | `0 9,17 * * *` | Om 09:00 en 17:00 |
| `-` | Een bereik | `0 9 * * 1-5` | Om 09:00 op weekdagen |
| `/` | Een stap | `*/15 * * * *` | Elk kwartier |

> [!TIP]
> Combineren mag: `0-30/10` is minuut 0, 10, 20 en 30. `1,15` in het veld dag is de 1e en de 15e.

Meer in de docs: [crontab.guru, een expressie in gewone taal](https://crontab.guru)

> **Toelichting:** Veel servers kennen ook afkortingen als @daily of @hourly, die hetzelfde betekenen als 0 0 * * * en 0 * * * *. In Laravel gebruik je die niet: daar heb je daily() en hourly(). Sommige tools gebruiken zes velden met een extra veld voor seconden, cron zelf kent dat niet.

<!-- slide 9 -->

### Lees een cron-expressie

Voorspel wat een preset doet voor je hem aanklikt. Typ daarna zelf `0 0 29 2 *`.

Interactieve uitleg van een cron-expressie: vijf velden (minuut, uur, dag van de maand, maand, weekdag) om zelf in te vullen, met een zin in het Nederlands en de volgende vijf keren dat de expressie loopt. Start met */15 9-17 * * 1-5, voorbeelden: schedule:run (* * * * *), Backup (30 3 * * *), Werkdagen 9u (0 9 * * 1-5), Kantooruren (*/15 9-17 * * 1-5), Om de 6 uur (0 */6 * * *), Eerste van de maand (0 0 1 * *), Vrijdag de 13e? (0 9 13 * 5), 31 februari (0 0 31 2 *).

> **Toelichting:** De explainer rekent in de tijdzone van je browser, de server en Laravel rekenen in hun eigen tijdzone. Probeer ook 0 0 31 2 *: die draait nooit, want 31 februari bestaat niet, en cron geeft daar geen foutmelding voor. crontab.guru doet hetzelfde in het Engels, handig als je een expressie tegenkomt buiten Laravel.

<!-- slide 10 -->

### Dag of weekdag

- Vul je zowel de dag van de maand als de weekdag in, dan draait cron als één van de twee klopt, niet als ze allebei kloppen
- `0 9 13 * 5` is dus elke 13e en elke vrijdag, niet vrijdag de 13e
- En `0 9 1-7 * 1` draait op de eerste zeven dagen van de maand én elke maandag, niet op de eerste maandag
- Laravel doet hetzelfde. De eerste maandag vang je met een voorwaarde: `->mondays()->when(fn () => now()->day <= 7)`

Meer in de docs: [crontab(5), onder DESCRIPTION](https://man7.org/linux/man-pages/man5/crontab.5.html)

> **Toelichting:** Laravel leest cron-expressies met de library dragonmantank/cron-expression. Die volgt dezelfde regel als cron op de server, met één klein verschil: cron op de server ziet een stap op een ster in het weekdagveld (elke tweede weekdag) als onbeperkt, de library niet. In de praktijk kom je dat zelden tegen.

<!-- slide 11 -->

### Wanneer draait \*/15 9-17 \* \* 1-5?

- A. Elk kwartier van 09:00 tot 17:45, maandag tot en met vrijdag
- B. Om 09:15 en 17:15 op weekdagen
- C. Elke 15 minuten, de hele dag
- D. Vijftien keer per dag, van maandag tot vrijdag

**Antwoord:** A. Elk kwartier van 09:00 tot 17:45, maandag tot en met vrijdag

\*/15 in het minutenveld is 0, 15, 30 en 45. Het bereik 9-17 neemt uur 17 mee, dus de laatste run is 17:45. 'Om 09:15 en 17:15' is de verleiding als je 9-17 als twee losse uren leest, maar een koppelteken is een bereik. Twee losse uren schrijf je met een komma: 9,17.

> **Toelichting:** Een handige manier om een expressie te lezen: begin achteraan. Weekdag 1-5 is maandag tot vrijdag, maand en dag zijn een ster, dus elke werkdag. Dan het uur, dan de minuut. Zo bouw je de zin op van groot naar klein.

<!-- slide 12 -->

### Je wil een rapport op de eerste maandag van de maand, om 8 uur. Wat doet 0 8 1-7 \* 1?

- A. Precies wat je wil: de eerste maandag
- B. Elke dag van de 1e tot de 7e, en daarnaast elke maandag
- C. Alleen als de 1e een maandag is
- D. Niets, cron weigert de expressie

**Antwoord:** B. Elke dag van de 1e tot de 7e, en daarnaast elke maandag

Dag van de maand en weekdag zijn allebei ingevuld, dus cron draait als één van de twee klopt. Dat geeft zeven dagen aan het begin van de maand plus elke maandag. De eerste maandag lijkt logisch, maar daarvoor zou cron beide velden moeten combineren, en dat doet hij net niet. In Laravel plan je mondays() en zet je er when(fn () => now()->day <= 7) bij.

> **Toelichting:** Het omgekeerde kan ook: wil je alleen de 13e als het een vrijdag is, dan plan je fridays() en zet je er when(fn () => now()->day === 13) bij. Voorwaarden in PHP zijn bijna altijd duidelijker dan een slimme cron-expressie. Wie je code later leest, zal je dankbaar zijn.

<!-- slide 13 -->

## 2. De scheduler

> **Toelichting:** Zonder scheduler staat de planning op de server, buiten je code. Met de scheduler zit ze in git, gaat ze mee in code review, en draait ze op een nieuwe server vanzelf mee. Dat is het verschil tussen een project dat je kan overdragen en een project met geheimen.

<!-- slide 14 -->

### Twintig cronjobs of één

#### Zonder scheduler

- Elke taak is een regel in de crontab van de server
- Die staat niet in git: wie weet nog wat er draait, en waarom?
- Iets wijzigen betekent SSH naar de server
- Een nieuwe server? Alles opnieuw overtypen, en hopen dat je niets vergeet.

#### Met de scheduler

- Eén regel op de server, die elke minuut `schedule:run` start
- De planning staat in `routes/console.php`, in git, in je pull request
- Iets wijzigen is een deploy
- `schedule:list` toont wat wanneer draait

> **Toelichting:** De scheduler lost ook een tweede probleem op: je hoeft de cron-syntax niet voor elke taak uit te schrijven, want dailyAt('09:00') leest makkelijker dan 0 9 * * *. Onder de motorkap zet Laravel het wel om naar een cron-expressie, en die zie je terug in schedule:list.

<!-- slide 15 -->

### Waar de planning staat

In een Laravel 13-project plan je taken in `routes/console.php`, met de facade `Schedule`. Elke regel is een taak met een frequentie. Wie liever alles in `bootstrap/app.php` heeft, kan daar `->withSchedule(function (Schedule $schedule) { ... })` gebruiken, maar `routes/console.php` is de standaard.

Een taak kan een artisan command zijn, een queued job of een closure. Welke je kiest, zie je verder in dit hoofdstuk.

Meer in de docs: [Defining schedules](https://laravel.com/docs/13.x/scheduling#defining-schedules)

> **Toelichting:** routes/console.php is ook de plek voor closure commands met Artisan::command(). Een planning met veel taken kan je ordenen met Schedule::daily()->group(), zodat taken die dezelfde instellingen delen die maar één keer krijgen.

<!-- slide 16 -->

### routes/console.php

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:run')->dailyAt('03:30');

Schedule::command('invoices:send-reminders')->weekdays()->at('09:00');

Schedule::job(new SyncStock)->hourly();

Schedule::command('model:prune')->daily();
```

1. `backup:run` van spatie/laravel-backup, elke nacht om 03:30: buiten het uur van de zomertijd.
2. Het command van de volgende slides: maandag tot vrijdag om 09:00.
3. `Schedule::job()` dispatcht de job elk uur in de queue. Het werk doet een worker.
4. `model:prune` ruimt elke nacht modellen op die de trait `Prunable` gebruiken.

> **Toelichting:** In Laravel 10 en ouder stond de planning in app/Console/Kernel.php, in een methode schedule(). Die kom je nog tegen in oudere projecten en tutorials: het idee is hetzelfde, alleen de plek is anders. backup:run komt uit het package spatie/laravel-backup, dat je apart installeert.

<!-- slide 17 -->

### Een command

```php
#[Signature('invoices:send-reminders')]
#[Description('Herinnering voor facturen die 14 dagen over tijd zijn')]
class SendInvoiceRemindersCommand extends Command
{
    public function handle(): void
    {
        // ...
    }
}
```

1. `Signature`: de naam waarmee je het command start, `php artisan invoices:send-reminders`. Die naam gebruik je ook in `routes/console.php`.
2. `Description`: de uitleg die `php artisan list` toont. Oudere code gebruikt de properties `$signature` en `$description`.
3. `handle()` doet het werk. `php artisan make:command` zet de class in `app/Console/Commands`. Zie ook [Writing commands](https://laravel.com/docs/13.x/artisan#writing-commands).

> **Toelichting:** Laravel vindt je commands vanzelf in app/Console/Commands, je hoeft ze nergens te registreren. Een command kan argumenten en opties hebben in zijn signature, zoals invoices:send-reminders {--dry-run}. Een optie die toont wat er zou gebeuren zonder iets te sturen, is handig voor elk command dat mails verstuurt.

<!-- slide 18 -->

### handle()

```php
public function handle(): void
{
    Invoice::overdue(days: 14)
        ->whereNull('reminded_at')
        ->each(function (Invoice $invoice) {
            Mail::to($invoice->customer)->queue(new InvoiceReminderMail($invoice));

            $invoice->update(['reminded_at' => now()]);
        });
}
```

1. `overdue()` is een local scope. `each()` haalt de facturen op in stukken van 1000, zodat ook 50.000 facturen in het geheugen passen.
2. De mail gaat in de queue: het command blijft snel, het versturen doet een worker.
3. `reminded_at` maakt het command veilig om twee keer te draaien: die factuur valt de volgende keer uit de query.

> **Toelichting:** Geef per factuur output met $this->info() en het factuurnummer, en op het einde een samenvatting met $this->comment(). Dan zie je in het logbestand van de scheduler wat er gebeurde. In een Pest-test start je het command met $this->artisan() en check je met Mail::fake() welke mails vertrokken.

<!-- slide 19 -->

### Lokaal testen

```console
$ php artisan schedule:list
  30 3 * * *    php artisan backup:run ................ Next Due: 13 hours from now
  0  9 * * 1-5  php artisan invoices:send-reminders ... Next Due: 19 hours from now
  0  * * * *    App\Jobs\SyncStock ................... Next Due: 52 minutes from now
  0  0 * * *    php artisan model:prune ............... Next Due: 9 hours from now
$ php artisan schedule:test
$ php artisan schedule:work
```

`schedule:list` toont elke taak met zijn cron-expressie en de volgende run. `schedule:test` laat je een taak kiezen en voert hem meteen uit. `schedule:work` doet lokaal wat cron op de server doet: elke minuut `schedule:run`. `composer run dev` start het niet.

> **Toelichting:** schedule:work start elke minuut schedule:run, maar voert pas een taak uit als die aan de beurt is. Een dagelijkse taak zie je dus niet lopen tenzij je wacht tot het juiste uur, daarvoor is schedule:test. Of start het command zelf met php artisan en zijn naam.

<!-- slide 20 -->

### Command, job of closure

|   | Wat draait er | Goed voor |
| --- | --- | --- |
| `Schedule::command()` | Een artisan command, in een eigen proces | Alles wat je ook met de hand wil kunnen starten |
| `Schedule::job()` | Alleen de dispatch. Het werk doet een queue worker. | Zwaar werk, met retries en backoff |
| `Schedule::call()` | Een closure, in het proces van schedule:run | Een oneliner. Met `name()` erbij voor overlap en onOneServer. |

> [!TIP]
> Voor alles wat meer dan een paar seconden duurt: een command dat jobs dispatcht. Het command kan je met de hand starten als er iets misging, en elke job krijgt zijn eigen retries en backoff van de queue.

> **Toelichting:** Een closure in routes/console.php is snel geschreven, maar je kan hem niet met de hand starten en niet apart testen. Voor alles behalve een oneliner kies je daarom een command of een job. Met Schedule::exec() kan je ook een shell-commando plannen, zoals een node-script.

<!-- slide 21 -->

### Schedule::job(new SyncStock)->hourly() staat een week in productie. Cron start schedule:run elke minuut, maar de voorraad is nooit gesynct. Wat ontbreekt er?

- A. onOneServer()
- B. Een queue worker
- C. withoutOverlapping()
- D. Een eigen cronjob voor SyncStock

**Antwoord:** B. Een queue worker

De scheduler dispatcht de job elk uur netjes in de queue. Zonder worker blijft hij daar staan: in de tabel jobs staan er nu 168. Een eigen cronjob voor SyncStock is de verleiding, maar de scheduler doet zijn werk al. Wat ontbreekt, is iemand die de queue leegmaakt.

> **Toelichting:** Daarom horen de lessen over queues en scheduling samen: elke Schedule::job() en elke mail die je in een command in de queue zet, heeft een worker nodig. Check op een nieuwe server altijd beide: draait de cronjob, en draait er een worker. Een snelle test: blijft de tabel jobs leeg?

<!-- slide 22 -->

## 3. Frequenties en voorwaarden

> **Toelichting:** Het meeste werk in een planning is kiezen wanneer: overdag of 's nachts, elke werkdag of elke dag, in welke tijdzone. Een verkeerde keuze valt pas op als een klant om 3 uur 's nachts een mail krijgt. Laravel geeft je leesbare methodes voor bijna elke frequentie.

<!-- slide 23 -->

### Frequenties

| Methode | Als cron | Draait |
| --- | --- | --- |
| `everyFiveMinutes()` | `*/5 * * * *` | Elke vijf minuten |
| `hourlyAt(17)` | `17 * * * *` | Elk uur, op minuut 17 |
| `daily()` | `0 0 * * *` | Om middernacht |
| `dailyAt('13:00')` | `0 13 * * *` | Elke dag om 13:00 |
| `weeklyOn(1, '8:00')` | `0 8 * * 1` | Maandag om 08:00 |
| `monthly()` | `0 0 1 * *` | De 1e van de maand, middernacht |
| `cron('0 9 * * 1-5')` | Wat je zelf schrijft | Alles wat hierboven ontbreekt |

`schedule:list` toont de cron-expressie die Laravel van elke methode maakt. Twijfel je, check het daar.

> **Toelichting:** Er zijn er nog veel meer: twiceDaily(1, 13), everyTwoHours(), lastDayOfMonth('15:00'), quarterly(). De volledige lijst staat in de docs onder Schedule frequency options. Gebruik liever een leesbare methode dan cron(), tenzij er echt geen past.

<!-- slide 24 -->

### Voorwaarden

```php
Schedule::command('stock:sync')
    ->everyFifteenMinutes()
    ->weekdays()
    ->between('8:00', '18:00')
    ->environments(['production'])
    ->when(fn () => config('services.erp.sync_enabled'));
```

1. Elk kwartier.
2. Alleen op weekdagen.
3. Alleen tussen 8 en 18 uur.
4. Alleen in productie. Zonder stuurt je staging-omgeving met gekopieerde klantdata taken naar echte klanten.
5. En alleen als de sync aan staat in de config. Voorwaarden stapelen: ze moeten allemaal kloppen.

> **Toelichting:** skip() is het omgekeerde van when(): de taak slaat over als de closure true teruggeeft. unlessBetween('23:00', '4:00') houdt een taak 's nachts tegen. De closure van when() draait telkens de taak aan de beurt is, hou hem dus licht: geen zware query.

<!-- slide 25 -->

### Tijdzones

- Laravel rekent standaard in UTC. Dat staat bij `timezone` in `config/app.php`.
- Dan draait `dailyAt('09:00')` in de zomer om 11 uur Belgische tijd, en in de winter om 10 uur
- Zet per taak `->timezone('Europe/Brussels')`, of voor alle taken `schedule_timezone` in `config/app.php`
- Doe dat voor taken die de klant ziet, zoals een herinnering om 9 uur. Voor nachtelijke taken is het riskanter, zie de volgende slide.

Meer in de docs: [Timezones](https://laravel.com/docs/13.x/scheduling#timezones)

> **Toelichting:** Zet de timezone van je app zelf best niet op Europe/Brussels: dan rekent Laravel ook je timestamps in lokale tijd, met dezelfde problemen rond de zomertijd. UTC in de database en een tijdzone bij het tonen is de gangbare aanpak. De scheduler is de uitzondering waar je wel lokaal wil rekenen, voor taken die de klant ziet.

<!-- slide 26 -->

### Stel: een backup om 02:30

Je plant de backup met `dailyAt('02:30')` en `timezone('Europe/Brussels')`. Op de laatste zondag van maart springt de klok van 02:00 naar 03:00: die nacht bestaat 02:30 niet, en de backup kan wegvallen. Op de laatste zondag van oktober gaat de klok van 03:00 terug naar 02:00: 02:30 komt twee keer, en de backup kan twee keer lopen.

> [!WARNING]
> De docs van Laravel raden tijdzones in de scheduler daarom af. Gebruik ze voor taken die de klant ziet, en hou nachtelijke taken buiten het uur tussen 2 en 3.

> **Toelichting:** De Belgische zomertijd start op de laatste zondag van maart en eindigt op de laatste zondag van oktober. Taken in UTC hebben dat probleem niet, maar schuiven wel een uur ten opzichte van de Belgische klok. Voor een backup maakt een uur verschuiving niets uit, een ontbrekende backup wel.

<!-- slide 27 -->

### Elke tien seconden

```php
Schedule::job(new CheckPendingPayments)->everyTenSeconds();

Schedule::command('orders:release')->everyTenSeconds()->runInBackground();
```

- Cron kan niet vaker dan elke minuut. Laravel lost dat op door `schedule:run` dan de hele minuut te laten draaien.
- Een trage taak schuift daardoor de rest op. Hou het werk kort: dispatch een job, of start het command in de achtergrond.
- Na een deploy draait die minuut nog met de oude code. Zet `php artisan schedule:interrupt` in je deployscript.

Meer in de docs: [Sub-minute scheduled tasks](https://laravel.com/docs/13.x/scheduling#sub-minute-scheduled-tasks)

> **Toelichting:** Vraag je af of je echt elke tien seconden iets moet checken. Vaak is een webhook of een event beter: dan reageer je op het moment dat er iets verandert, in plaats van telkens te vragen of er iets veranderd is. Zie de les over online betalingen voor webhooks van Mollie.

<!-- slide 28 -->

## 4. Als taken botsen

> **Toelichting:** Zolang een taak kort is en op één server draait, gaat alles goed. Problemen beginnen als een taak langer duurt dan verwacht, of als je app op meer dan één server draait. Dat tweede gebeurt sneller dan je denkt: autoscaling op Laravel Cloud zet er zonder waarschuwing een replica bij.

<!-- slide 29 -->

### Overlap

Een sync start elke minuut en duurt 3,5 minuut. Tel bovenaan hoeveel runs er tegelijk lopen, en kijk onderaan welke minuten `withoutOverlapping()` overslaat.

Tijdlijn van 10 minuten voor een taak die elke minuut start en 3.5 minuten duurt. Zonder withoutOverlapping lopen er tot 4 tegelijk. Met withoutOverlapping start hij maar 3 keer, op minuut 0, 4, 8, en slaat de scheduler de andere minuten over.

> **Toelichting:** Standaard kijkt Laravel niet of een vorige run nog loopt, want dat vraagt een lock in de cache. Een taak die elke minuut draait en meestal tien seconden duurt, kan op een drukke dag toch drie minuten nodig hebben. Zet withoutOverlapping() op elke frequente taak die data aanpast.

<!-- slide 30 -->

### withoutOverlapping

```php
Schedule::command('stock:sync')
    ->everyMinute()
    ->withoutOverlapping();

Schedule::command('reports:generate')
    ->hourly()
    ->withoutOverlapping(30);
```

1. Start de sync niet opnieuw zolang de vorige run nog loopt. Laravel houdt dat bij met een lock in de cache.
2. De lock vervalt standaard na 24 uur. Met een getal kies je zelf, in minuten: hier na 30.

> [!WARNING]
> Crasht de server midden in een taak, dan blijft de lock staan tot hij vervalt, en slaat de taak al die tijd over. Wis hem met `schedule:clear-cache`.

> **Toelichting:** De naam van de lock maakt Laravel uit de expressie en het commando. Pas je de frequentie aan, dan verandert de naam, en een oude lock die bleef hangen doet er dan niet meer toe. schedule:clear-cache wist alle locks van de scheduler in één keer.

<!-- slide 31 -->

### onOneServer

```php
Schedule::command('invoices:send-reminders')
    ->weekdays()
    ->at('09:00')
    ->onOneServer();
```

Stel: je app draait op drie replicas in Laravel Cloud, of op twee servers achter een load balancer. Elke server heeft de cronjob, dus elke server draait `schedule:run`, en elke klant krijgt drie herinneringen. Met `onOneServer()` neemt de eerste server een lock, en slaan de andere over.

> [!NOTE]
> Dat werkt alleen als alle servers dezelfde cache delen: database, redis, memcached of dynamodb. Een closure heeft er ook een `name()` voor nodig.

> **Toelichting:** onOneServer gebruikt de default cache van je app, of een andere via Schedule::useCache(). Heb je dezelfde job met verschillende parameters gepland, geef dan elke taak een eigen name(), anders ziet Laravel ze als één taak. Laravel Cloud raadt in zijn docs zelf aan om onOneServer te gebruiken zodra je meer dan één replica hebt.

<!-- slide 32 -->

### runInBackground

```php
Schedule::command('reports:generate')->dailyAt('04:00')->runInBackground();

Schedule::command('subscriptions:renew')->dailyAt('04:00');
```

Taken op hetzelfde moment lopen na elkaar, in de volgorde van `routes/console.php`. Duurt het rapport twintig minuten, dan worden de abonnementen pas om 04:20 verlengd. `runInBackground()` start het rapport in een eigen proces, zodat de tweede taak niet wacht. Het werkt alleen met `command()` en `exec()`.

Meer in de docs: [Background tasks](https://laravel.com/docs/13.x/scheduling#background-tasks)

> **Toelichting:** Een taak in de achtergrond draait in een apart PHP-proces, dus hooks als onFailure() komen pas als dat proces klaar is. Heb je veel zware taken op hetzelfde moment, spreid ze dan liever: 04:00, 04:10, 04:20. Of zet het zware werk in jobs, dan verdelen je workers het.

<!-- slide 33 -->

### Je app draait op drie replicas in Laravel Cloud. De maandelijkse factuurrun stuurt elke klant drie mails. Wat vergat je?

- A. withoutOverlapping()
- B. runInBackground()
- C. onOneServer()
- D. Een tijdzone

**Antwoord:** C. onOneServer()

Elke replica draait schedule:run, dus elke replica start de factuurrun. onOneServer laat maar één server de taak uitvoeren. withoutOverlapping lijkt ook te helpen, maar houdt alleen een start tegen zolang een vorige run nog loopt. Is de run op de eerste replica al klaar als de derde begint, dan loopt hij gewoon opnieuw.

> **Toelichting:** Een goede gewoonte: zet onOneServer() op elke taak die iets naar buiten stuurt, zoals mails, API-calls of betalingen, ook als je nu maar één server hebt. Het kost niets zolang je cache gedeeld is, en de dag dat er een tweede server bijkomt, ben je al klaar.

<!-- slide 34 -->

## 5. Op de server

> **Toelichting:** Alles wat je tot nu toe schreef, draait pas als iemand elke minuut schedule:run start. Dat is één regel op de server, maar die regel vergeten is de meest voorkomende fout bij een nieuwe omgeving. Zet hem op de checklist van elke oplevering.

<!-- slide 35 -->

### De enige cronjob

```bash
* * * * * cd /home/forge/agency.be && php artisan schedule:run >> /dev/null 2>&1
```

| Stuk | Waarom |
| --- | --- |
| `* * * * *` | Elke minuut |
| `cd /home/forge/agency.be` | Naar de map van je project |
| `php artisan schedule:run` | Laravel kijkt welke taken nu aan de beurt zijn |
| `>> /dev/null 2>&1` | De output weggooien. Je taken loggen zelf. |

Meer in de docs: [Running the scheduler](https://laravel.com/docs/13.x/scheduling#running-the-scheduler)

> **Toelichting:** Wil je de output toch zien, vervang dan /dev/null door een logbestand, zoals storage/logs/scheduler.log. Handig tijdens het opzetten, daarna gooi je de output best weer weg. Vindt cron PHP niet, schrijf dan het volledige pad, zoals /usr/bin/php.

<!-- slide 36 -->

### Welke gebruiker

Zet de regel met `crontab -e` in de crontab van dezelfde gebruiker als je webserver, zoals `forge` of `www-data`. Niet als root.

> [!WARNING]
> Draait de scheduler als root, dan maken je taken logbestanden in `storage/logs` die de webserver daarna niet meer mag aanpassen. De volgende keer dat je site iets logt, crasht hij op een permission error.

> **Toelichting:** Met crontab -l zie je de crontab van de huidige gebruiker, met sudo crontab -u forge -l die van een andere. Zo check je op een onbekende server snel of en waar de scheduler staat. Vind je de regel bij twee gebruikers, dan draait elke taak ook twee keer.

<!-- slide 37 -->

### Forge, Cloud of zelf

- **[Eigen server](https://laravel.com/docs/13.x/scheduling#running-the-scheduler)**: `crontab -e` als de gebruiker van je site, en die ene regel erin.
- **[Laravel Forge](https://laravel.com/forge/docs/resources/scheduler)**: Onder Processes, Scheduler: een job met `schedule:run`, elke minuut. Of de Laravel-integratie van je site.
- **[Laravel Cloud](https://laravel.com/cloud/docs/scheduled-tasks)**: De toggle Scheduler op je App cluster, en opnieuw deployen. Meerdere replicas? `onOneServer()`.

> **Toelichting:** Op Laravel Cloud met scale to zero wekt Cloud je omgeving zelf om geplande taken te draaien. Het gebruikt daarvoor de output van schedule:list van je laatste deploy: een wijziging aan je planning werkt dus pas na de volgende deploy. Plan dan ook geen taken die korter na elkaar komen dan je sleep timeout, anders slaapt je omgeving nooit.

<!-- slide 38 -->

### Na een deploy

- `schedule:run` start elke minuut opnieuw, dus nieuwe code ziet hij vanzelf. Een queue worker niet: die heeft `queue:restart` nodig, ook voor je geplande jobs.
- Heb je taken die vaker dan elke minuut lopen, zet dan `schedule:interrupt` in je deployscript
- Even alles stilleggen zonder deploy kan met `schedule:pause`, en weer starten met `schedule:continue`

Meer in de docs: [Pausing scheduled tasks](https://laravel.com/docs/13.x/scheduling#pausing-scheduled-tasks)

> **Toelichting:** schedule:pause schrijft een vlag in de cache, net als queue:restart. Met een array-cache werkt het dus niet. Een taak die ook tijdens een pauze moet lopen, geef je evenWhenPaused().

<!-- slide 39 -->

### Maintenance mode

Staat je app in maintenance mode (`php artisan down`), dan draait de scheduler geen enkele taak. Ze worden achteraf ook niet ingehaald.

Stel: een migratie duurt een uur, van 8:30 tot 9:30, met de app in maintenance mode. De herinneringen van 9 uur vertrekken die dag niet. Moet een taak toch lopen, zet er dan `evenInMaintenanceMode()` achter.

Meer in de docs: [Maintenance mode](https://laravel.com/docs/13.x/scheduling#maintenance-mode)

> **Toelichting:** php artisan down met de optie --secret laat jou nog binnen via een geheime url, maar de scheduler blijft stil. Plan een lange migratie daarom buiten de uren van je belangrijkste taken. Of schrijf je migratie zo dat de app niet down hoeft, bijvoorbeeld door een kolom eerst toe te voegen en pas later te gebruiken.

<!-- slide 40 -->

## 6. Monitoring

> **Toelichting:** Een geplande taak heeft geen gebruiker die klaagt als hij faalt. Niemand merkt een ontbrekende backup tot je hem nodig hebt, en niemand merkt een herinnering die niet vertrok tot de klant zijn geld mist. Monitoring is dus geen extraatje, maar het enige wat je vertelt dat de planning werkt.

<!-- slide 41 -->

**Een cronjob die niet draait, geeft geen foutmelding.**

Geen exception, geen mail, geen rood scherm. Gewoon niets, tot iemand vraagt waar de backup van vorige week is.

> **Toelichting:** Maak voor je project een lijst van de geplande taken, met wat er misgaat als ze een week niet lopen. Voor elke taak waar het antwoord pijn doet, wil je een heartbeat of een monitor. De rest mag stil falen.

<!-- slide 42 -->

### Output en hooks

```php
Schedule::command('backup:run')
    ->dailyAt('03:30')
    ->appendOutputTo(storage_path('logs/backup.log'))
    ->onFailure(function (Stringable $output) {
        Log::error('Backup mislukt', ['output' => (string) $output]);
    });
```

1. `appendOutputTo()` schrijft de output van elke run achteraan in een logbestand. Werkt alleen met `command()` en `exec()`.
2. `onFailure()` draait als het command eindigt met een exit code die niet 0 is. Return dus `self::FAILURE` als het misloopt, anders denkt de scheduler dat alles goed ging.

Dit vangt een taak die faalt. Een taak die nooit start, omdat de cronjob weg is of de server hangt, vangt het niet.

> **Toelichting:** Er zijn ook before() en after(), die altijd draaien, en onSuccess(). Met emailOutputOnFailure() krijg je de output per mail als het command faalt. In elke hook kan je de output binnenkrijgen als Stringable, zoals in het voorbeeld.

<!-- slide 43 -->

### Heartbeats

```php
Schedule::command('backup:run')
    ->dailyAt('03:30')
    ->pingOnSuccess(config('services.heartbeats.backup'));
```

De taak pingt een url als hij klaar is. Blijft die ping uit, dan krijg jij een melding: stilte wordt het alarm. Zet de url in `config/services.php`, zodat staging geen pings stuurt naar de monitor van productie.

- **[Forge heartbeats](https://laravel.com/forge/docs/resources/scheduler#heartbeats)**
- **[Oh Dear, scheduled tasks](https://ohdear.app/docs/features/cron-job-monitoring)**

> **Toelichting:** Er is ook pingBefore(), die een ping stuurt als de taak start. Met een ping voor en na ziet een dienst als Oh Dear ook hoe lang een taak duurt, en of hij vastloopt. thenPing() pingt na elke run, pingOnSuccess() alleen als het command slaagt.

<!-- slide 44 -->

### Schedule monitor

- Het package spatie/laravel-schedule-monitor logt elke start, elke finish en elke fout van je geplande taken in de database
- `schedule-monitor:list` toont wat te laat is of faalde
- Koppel Oh Dear, en je krijgt een melding als een taak niet op tijd klaar is
- Een taak die wat langer mag duren, geef je `->graceTimeInMinutes(10)`. De standaard is 5 minuten.

Meer in de docs: [spatie/laravel-schedule-monitor](https://github.com/spatie/laravel-schedule-monitor)

> **Toelichting:** Het package schrijft een rij per start, finish en fout in de tabel monitored_scheduled_task_log_items. Die groeit snel: plan model:prune voor het model MonitoredScheduledTaskLogItem, zoals de README uitlegt. Een taak die je niet wil bewaken, geef je doNotMonitor().

<!-- slide 45 -->

### Schedule monitor installeren

```console
$ composer require spatie/laravel-schedule-monitor
$ php artisan vendor:publish --tag="schedule-monitor-migrations"
$ php artisan migrate
$ php artisan schedule-monitor:sync
```

`schedule-monitor:sync` leest je planning in, en hoort dus in je deployscript. De README toont nog voorbeelden met `app/Console/Kernel.php`: in Laravel 13 zet je `monitorName()` en `graceTimeInMinutes()` gewoon achter je taak in `routes/console.php`.

> **Toelichting:** Zonder schedule-monitor:sync kent het package je taken niet, en logt het niets. Wijzig je de naam van een taak, dan maakt de sync een nieuwe monitor en verdwijnen de oude logs. Kies dus een vaste naam met monitorName() voor taken die je lang wil volgen.

<!-- slide 46 -->

### Wat doet een heartbeat?

- A. Een taak die elke minuut checkt of de server nog leeft
- B. Je taak pingt een url als hij klaar is, en een dienst slaat alarm als die ping uitblijft
- C. Hij herstart een taak die vastloopt
- D. Hij logt de output van een taak naar een bestand

**Antwoord:** B. Je taak pingt een url als hij klaar is, en een dienst slaat alarm als die ping uitblijft

Een foutmelding vangt alleen een taak die start en faalt. Een heartbeat vangt ook de taak die nooit start, want dan blijft de ping uit. Een taak die elke minuut checkt of alles leeft, klinkt als een hartslag, maar die draait op dezelfde cron: valt cron weg, dan zwijgt ook je check.

> **Toelichting:** Een heartbeat vertelt dat een taak gedraaid heeft, niet dat hij het juiste deed. Een backup die elke nacht een leeg bestand maakt, pingt gewoon. Zet af en toe een backup echt terug, op een testomgeving.

<!-- slide 47 -->

### Opdracht: Herinneringen voor onbetaalde facturen

Huistaak, in je Agency-project of in een nieuw Laravel-project met een Invoice-model. Vast? Kijk naar de slides Een command, handle() en onOneServer, of naar de [docs over scheduling](https://laravel.com/docs/13.x/scheduling).

1. Maak een command invoices:send-reminders dat per factuur die 14 dagen over tijd is een mail in de queue zet en reminded_at invult
2. Plan het in routes/console.php op weekdagen om 09:00, in Europe/Brussels, met withoutOverlapping en onOneServer
3. Test met schedule:list, schedule:test en schedule:work, en kijk in Mailpit of de mail catcher van Herd
4. Klaar als schedule:list 09:00 op de volgende werkdag toont, en het command twee keer na elkaar geen tweede mail stuurt

> **Toelichting:** Begin met het command en start het met de hand, met twee facturen in je database die over tijd zijn. Werkt dat, dan pas plannen. Een factory met een state overdue maakt die testdata snel, zie de les over testen.

## Begrippen

- **Cron-expressie**: Vijf velden die zeggen wanneer iets draait: minuut, uur, dag van de maand, maand en weekdag. 30 2 \* \* \* is elke dag om 02:30. (slide 7)
- **Lijst**: Een komma in een cron-veld: meerdere waarden. 0 9,17 \* \* \* draait om 09:00 en om 17:00. (slide 8)
- **Bereik**: Een koppelteken in een cron-veld: alles van tot en met. 1-5 in het veld weekdag is maandag tot en met vrijdag. (slide 8)
- **Stap**: Een schuine streep in een cron-veld: om de zoveel. \*/15 in het veld minuut is elk kwartier, 0-30/10 is minuut 0, 10, 20 en 30. (slide 8)
- **Dag of weekdag**: Vul je in een cron-expressie zowel de dag van de maand als de weekdag in, dan draait hij als één van de twee klopt. 0 9 13 \* 5 is elke 13e en elke vrijdag, niet vrijdag de 13e. (slide 10)
- **Scheduler**: De planner van Laravel. Je zet al je taken in routes/console.php, en één cronjob op de server start elke minuut schedule:run, dat beslist wat er aan de beurt is. (slide 14)
- **schedule:run**: Het commando dat cron elke minuut start. Het kijkt welke taken nu aan de beurt zijn en voert ze uit. (slide 35)
- **schedule:work**: Doet lokaal wat cron op de server doet: elke minuut schedule:run, tot je het stopt. composer run dev start het niet. (slide 19)
- **schedule:list**: Toont elke geplande taak met zijn cron-expressie en wanneer hij de volgende keer draait. De snelste check na een wijziging. (slide 19)
- **Artisan command**: Een class in app/Console/Commands met een handle()-methode, die je start met php artisan en een naam. In Laravel 13 met de attributes Signature en Description. (slide 17)
- **Schedule::job**: Plant een queued job in. De scheduler dispatcht hem alleen, het werk doet een queue worker. Zonder worker gebeurt er dus niets. (slide 20)
- **withoutOverlapping**: Start een taak niet opnieuw zolang de vorige run nog loopt. Werkt met een lock in de cache, die standaard na 24 uur vervalt. Vastgelopen? schedule:clear-cache. (slide 30)
- **onOneServer**: Laat een taak op één server draaien, ook als schedule:run op meerdere servers of replicas loopt. De eerste neemt een lock in de gedeelde cache, de rest slaat over. (slide 31)
- **runInBackground**: Start een command in een apart proces, zodat taken die op hetzelfde moment gepland staan niet op elkaar wachten. Alleen voor command() en exec(). (slide 32)
- **Zomeruur**: In maart bestaat 02:30 niet in België, in oktober komt het twee keer. Een taak met een tijdzone in dat uur kan dan wegvallen of twee keer lopen. Plan nachtelijke taken buiten 2 tot 3 uur. (slide 26)
- **schedule_timezone**: Een optie in config/app.php die alle geplande taken in een tijdzone zet, zoals Europe/Brussels. Per taak kan het met ->timezone(). (slide 25)
- **Sub-minute task**: Een taak die vaker dan elke minuut draait, zoals everyTenSeconds(). schedule:run blijft dan de hele minuut lopen, dus dispatch best een job en zet schedule:interrupt in je deployscript. (slide 27)
- **Heartbeat**: Een ping die een taak stuurt als hij klaar is. Blijft die ping uit, dan slaat een dienst als Forge of Oh Dear alarm. Stilte wordt zo een melding. (slide 43)
- **Schedule monitor**: Het package spatie/laravel-schedule-monitor. Het logt elke start, finish en fout van je geplande taken, toont wat te laat is, en kan Oh Dear laten verwittigen. (slide 44)
- **Grace time**: Hoeveel minuten een taak mag doen over zijn werk voor de schedule monitor hem te laat vindt. Standaard 5, per taak met graceTimeInMinutes(). (slide 44)

## Links en bronnen

### In de presentatie

- [crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html) (slide 6, slide 10): De vijf velden, en dag of weekdag als ze allebei ingevuld zijn
- [crontab.guru](https://crontab.guru) (slide 8): Een cron-expressie in gewone taal
- [Defining schedules](https://laravel.com/docs/13.x/scheduling#defining-schedules) (slide 15)
- [Writing commands](https://laravel.com/docs/13.x/artisan#writing-commands) (slide 17)
- [Timezones](https://laravel.com/docs/13.x/scheduling#timezones) (slide 25)
- [Sub-minute scheduled tasks](https://laravel.com/docs/13.x/scheduling#sub-minute-scheduled-tasks) (slide 27)
- [Background tasks](https://laravel.com/docs/13.x/scheduling#background-tasks) (slide 32)
- [Running the scheduler](https://laravel.com/docs/13.x/scheduling#running-the-scheduler) (slide 35, slide 37)
- [Forge: scheduler](https://laravel.com/forge/docs/resources/scheduler) (slide 37): Scheduled jobs en heartbeats
- [Laravel Cloud: scheduled tasks](https://laravel.com/cloud/docs/scheduled-tasks) (slide 37): De Scheduler-toggle en onOneServer bij meerdere replicas
- [Pausing scheduled tasks](https://laravel.com/docs/13.x/scheduling#pausing-scheduled-tasks) (slide 38)
- [Maintenance mode](https://laravel.com/docs/13.x/scheduling#maintenance-mode) (slide 39)
- [Forge heartbeats](https://laravel.com/forge/docs/resources/scheduler#heartbeats) (slide 43)
- [Oh Dear, scheduled tasks](https://ohdear.app/docs/features/cron-job-monitoring) (slide 43)
- [spatie/laravel-schedule-monitor](https://github.com/spatie/laravel-schedule-monitor) (slide 44): Logt elke run, grace time, koppeling met Oh Dear
- [Task scheduling](https://laravel.com/docs/13.x/scheduling) (slide 47): Laravel 13: Schedule-facade, frequenties, overlap, onOneServer, hooks, schedule:work

### Bronnen

- [Artisan console](https://laravel.com/docs/13.x/artisan): Commands met de attributes Signature en Description
- [dragonmantank/cron-expression](https://github.com/dragonmantank/cron-expression): De parser achter de scheduler van Laravel
