# 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.

Source: https://boring-observability.dev/skyline/docs/scheduled-tasks
Section: Observability — Skyline for Laravel documentation
Updated: 2026-11-10

---

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](#pause) from the dashboard. |
| Missed | It was due and no scheduler ran it or skipped it. See [missed runs](#missed). |
| 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](#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](https://boring-observability.dev/skyline/docs/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.

```php
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

```php
'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](https://boring-observability.dev/skyline/docs/api#schedule), which return `404`. Pause and Run now sit behind the `viewHorizon` gate like every other dashboard action; see [Security & access](https://boring-observability.dev/skyline/docs/security-and-access#destructive).


## Common questions

### How can I see whether my Laravel scheduled tasks actually ran?

Laravel keeps no history of scheduled runs: schedule:list shows only what is defined and when it is next due. With Skyline 1.7 or later, the Schedule screen at /horizon/schedule lists every task your scheduler runs with the outcome and duration of its last run and a strip of its last 30 slots, each marked ran, failed, skipped, paused or missed. Nothing needs configuring, because Skyline records the scheduler's own events.

### How does Skyline know a scheduled task was missed?

Every minute, schedule:run tells Skyline which tasks it is considering, and every run or skip is recorded against the slot it was due in. A slot that no scheduler ran or skipped is marked missed once it is a minute old and no scheduler is still working through that minute's tasks. When no scheduler has started a tick for a couple of minutes, the screen says so.

### Does pausing a scheduled task in Skyline stop it on every server?

Yes. A pause is stored in Horizon's Redis and checked by every scheduler before the task runs, so it holds on every host until you resume it, and it survives deploys. If that Redis connection cannot be reached, the task runs rather than being held back.

### Can I catch a typo in my Laravel schedule before it reaches production?

Yes. Call Horizon::assertScheduleIsValid() in a test. It resolves the whole schedule and fails with every invalid cron expression or timezone, every Artisan command, job class or callback that does not exist, and any two tasks that cannot be told apart. The scheduler itself only finds these when the task comes up.
