The scheduler replaces a crontab full of entries with one:
* * * * * cd /path/to/app && php artisan schedule:run
Everything else is PHP, in the repository, reviewed like any other change:
// routes/console.php
Schedule::command('model:prune')->daily();
Schedule::command('sanctum:prune-expired')->daily();
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches')->daily();
Four lines of housekeeping, each one keeping a promise another chapter makes: expired tokens from Chapter 4, old failed jobs and finished batches from Chapter 10, and the retention rules that Chapter 17 will put on the models. (Laravel documentation: Task Scheduling.)
To see what the application believes its schedule is:
php artisan schedule:list
That prints every task with its expression and the next time it will run. When someone asks “is that job still scheduled?”, this is the answer, and it can’t be out of date.
Tasks That Mustn’t Overlap or Double Up
A task that takes longer than its interval will start again while it is still running. On two servers, it will run on both. Neither is what you meant. Here is the first of those four lines again, protected:
Schedule::command('model:prune')
->daily()
->withoutOverlapping()
->onOneServer();
withoutOverlapping() skips a run if the previous one hasn’t finished. onOneServer() makes sure that only one of your servers runs it each time. Both work through a lock in your cache, which means the cache has to be shared between servers: Redis or the database, not a file on each machine. With a local cache store, onOneServer() protects nothing, and it won’t tell you.
Let the Scheduler Dispatch and the Queue Work
schedule:run executes due tasks one after another. A task that takes ten minutes delays everything due after it.
So the scheduler’s job is to start work, not to do it:
Schedule::job(new ExportMonthlyUsage)->monthlyOn(1, '03:00');
That puts a job on the queue and returns at once. The work then has everything Chapter 10 built: retries, backoff, a failed() method, and a place in failed_jobs when it gives up. A long task run inline has none of those.
Keep commands in the schedule for short, idempotent housekeeping. Send anything heavy to the queue.
Knowing a Task Stopped
Here is the failure this chapter opened with. A request that fails produces a 500 and an exception. A job that fails lands in failed_jobs. A scheduled task that never starts produces nothing. No exception, no log line, no row. The cron entry was lost in a server migration, or the task has been skipped for a week because a lock was never released, and the first sign is a table that has grown for a month.
You can’t alert on an error that doesn’t happen. You alert on the absence of a success. The pruning line once more, with both kinds of alarm:
// routes/console.php
Schedule::command('model:prune')
->daily()
->onFailure(function (): void {
report(new ScheduledTaskFailed('model:prune'));
})
->pingOnSuccess(config('services.heartbeat.prune'));
onFailure() covers the task that ran and returned a non-zero exit code, which is why exit codes matter.
pingOnSuccess() covers everything else. After each successful run it calls a URL at a monitoring service, and that service raises the alarm when the call doesn’t arrive on time. This is a heartbeat, sometimes called a dead man’s switch, and it is the only kind of monitor that catches a task that never ran. Use pingOnSuccess() and not thenPing(), which calls the URL after a failed run too and so reports a heartbeat from a task that is dying. Most uptime services offer heartbeats, and it is one line per task, plus one URL per task in config/services.php. (Laravel documentation: Task Scheduling › Pinging URLs.)
Put a heartbeat on every scheduled task whose absence would eventually hurt. For this API that is pruning. In the service that provisions sites, it is also Chapter 10’s stuck-work sweep.