Scheduled tasks: what the scheduler ran, and what it missed
The Schedule screen: every scheduled task with its recent runs and missed slots, pause and Run now, and a test for the whole schedule.
Laravel's scheduler keeps no history. php artisan schedule:list shows what is defined and when it is
next due, but whether last night's run happened, failed or was skipped is only known if the task logged it itself.
A task that stopped running because a deploy removed it, or because cron stopped calling schedule:run,
leaves no trace at all.
Since 1.7 the dashboard has a Schedule screen at /horizon/schedule. It lists every task
your scheduler runs with what became of each run, including the runs no scheduler started, and lets you pause a task
or run it now. Nothing needs setting up: Skyline listens to the scheduler's own events, so the screen fills in the
next time schedule:run or schedule:work runs.
What the screen shows#
The header says when a scheduler last started a tick, and on which host. Below it are four counts: tasks, tasks whose last run failed, missed runs, and paused tasks. Each task then gets a row:
| Column | What it holds |
|---|---|
| Task | Its type (command, job, closure or shell), its name or description, and its flags: withoutOverlapping(), onOneServer(), runInBackground(), evenInMaintenanceMode() and any environments(). If its last run failed, the exception is shown under the name. |
| Schedule | The cron expression, with the timezone it runs in and, for a sub-minute task, how often it repeats. |
| Next due | When it next comes up, or Paused. |
| Last run | The outcome, how long ago, and how long it took. A Run now shows who asked for it. |
| Recent runs | A strip of the last 30 slots the task was due in, newest on the right. Hover a mark for its time, duration, skip reason, exit code, exception and host. |
Each mark in the strip is one of these:
| Mark | Meaning |
|---|---|
| Ran | The task ran and finished cleanly. |
| Failed | A command exited non-zero, or a closure or job threw. The exit code or the exception is kept. |
| Running | It started and has not finished yet. |
| Skipped | A scheduler considered it and held it back, either because an earlier run still held its overlap lock or because its when() or skip() condition said so. |
| Paused | It was due while paused from the dashboard. |
| Missed | It was due and no scheduler ran it or skipped it. See missed runs. |
| No end reported | It started, or was queued by Run now, more than a day ago and never reported finishing, usually because its process was killed. |
| Queued | A Run now waiting for a worker to pick it up. |
| Rounded mark | A Run now, outside the schedule. It is kept apart from the cron slots, so clicking it repeatedly never pushes a real slot out of the strip. |
Where the task list comes from#
Every minute, schedule:run registers the tasks it is about to consider, so the screen shows the schedule
your scheduler actually runs, not the one the web server's copy of the code would define. If the scheduler host is a
release behind, that is what you see.
A task no scheduler has reported for an hour is dropped, with its history, but only while some scheduler is still reporting the others. A scheduler that stops leaves the whole schedule on screen, which is when you need it.
A runInBackground() task is recorded when it finishes, from its own process, through the
schedule:finish call the framework already makes.
More than one scheduler host#
Each mark is a minute the task was due in, and every scheduler host reports on it. The most telling report wins, a
run over a skip, so an onOneServer() or withoutOverlapping() task that the other hosts skip
every time still reads as having run. A sub-minute task's runs within one minute share a mark, with a count, and the
longest of them is the duration shown.
Missed runs#
A slot no scheduler reported on, neither ran nor skipped, is shown as missed once it is a minute old and no scheduler is still working through that minute's tasks. The wait is there because a scheduler runs foreground tasks one after another, so a task queued behind a slow one starts late rather than not at all. Missed runs are not made up afterwards, by Laravel or by Skyline.
When no scheduler has started a tick for a couple of minutes, the screen also says so above the task list, with a
reminder to check that cron still runs php artisan schedule:run every minute or that
schedule:work is running. A task limited by environments() to an environment no reporting
scheduler runs in is never counted as missed.
Missed and failed runs are shown on this screen. They do not send alerts.
Pausing a task#
Pause holds a task back on every scheduler until it is resumed, and the pause survives deploys. Each slot that comes up while it is paused is recorded as paused, so the gap in the strip explains itself. The skipped runs are not made up when you resume.
Checking for a pause is one Redis read per due task. If Horizon's Redis connection can't be reached, the task runs, so a Redis outage never stops your schedule.
Run now#
Run now queues a job that one of your queue workers picks up. It goes to the connection and queue in
horizon.schedule.connection and horizon.schedule.queue, which are the application's
defaults unless you set them. A connection no worker processes (sync, deferred or
null) is refused.
The task never runs inside the worker. A command is launched in the background from it, the way the scheduler launches
a runInBackground() task, so no worker timeout or retry_after applies to it. A
closure, an invokable class or a scheduled job has no process of its own, so the worker launches
php artisan horizon:run-scheduled-task in the background to run it, as the worker's user and in its
environment. A scheduled job only dispatches its job, as it would on schedule.
The worker must be running the same release as the scheduler, or the run fails saying so. The run shows in the strip as a rounded mark, with the name or email of whoever asked for it.
Checking the schedule in a test#
The scheduler only parses a cron expression, resolves a command or builds a job when the task comes up, so a typo
survives every deploy check and fails at the first run. Horizon::assertScheduleIsValid() resolves the
whole schedule and fails with every problem it finds: an invalid cron expression or timezone, an Artisan command that
does not exist, a job class or callback that does not exist, and two tasks that can't be told apart.
use Laravel\Horizon\Horizon;
public function test_the_schedule_is_valid(): void
{
Horizon::assertScheduleIsValid();
}
It fails through PHPUnit's assertions when PHPUnit is loaded, which covers Pest, and throws
Laravel\Horizon\Exceptions\InvalidSchedule anywhere else.
Configuration#
'schedule' => [
'enabled' => env('HORIZON_SCHEDULE', true),
'connection' => env('HORIZON_SCHEDULE_CONNECTION'),
'queue' => env('HORIZON_SCHEDULE_QUEUE'),
],
A config/horizon.php published before 1.7 has no schedule block, and the screen is on
anyway. Recording costs a couple of Redis writes per run, made from the scheduler, plus the one read per due task
for the pause check.
HORIZON_SCHEDULE=false turns off recording, the pause check, the Schedule entry in the dashboard's
navigation and the schedule endpoints, which return
404. Pause and Run now sit behind the viewHorizon gate like every other dashboard action;
see Security & access.