# Monitor the Laravel scheduler, and why tasks silently don't run

> Laravel's scheduler runs only when cron calls schedule:run each minute. Missing cron, stuck locks and timezones skip tasks silently; a heartbeat catches it.

Laravel's scheduler runs nothing by itself: cron must call `php artisan schedule:run` every minute, and each run starts the tasks due at that minute. When a task doesn't run there is usually no error at all, so the only reliable check expects a signal from the task and alerts when it doesn't arrive.

Everything below was run on Laravel 13.40, against the [Laravel 13.x scheduling docs](https://laravel.com/docs/13.x/scheduling).

## How the scheduler runs

You define tasks in `routes/console.php`:

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

Schedule::command('reports:send')->everyMinute()->withoutOverlapping();
Schedule::command('invoices:sync')->hourly()->onOneServer();
Schedule::command('backup:run')->dailyAt('02:30')->timezone('Europe/Amsterdam');
```

On the server, one cron entry runs the scheduler every minute:

```text
* * * * * cd PROJECT_PATH && php artisan schedule:run >> /dev/null 2>&1
```

`PROJECT_PATH` is your app's directory, such as `/home/forge/example.com/current`. Locally, `php artisan schedule:work` does the same in the foreground.

`schedule:list` shows what will run, and when:

```text
 * * * * * php artisan reports:send .. Next Due: 14 seconds from now
 0 * * * * php artisan invoices:sync .. Next Due: 15 minutes from now
 30 1 * * * php artisan backup:run .. Next Due: 1 hour from now
```

The backup shows `30 1 * * *`, not `30 2`: the app runs in UTC and Amsterdam is UTC+1 in November (`--timezone=Europe/Amsterdam` shows local times).

## Why a task doesn't run

### No cron entry, or the wrong one

The most common cause: a new server, a container without cron, or a crontab for the wrong user, and nothing complains. Check `crontab -l` as the user that owns the app (`forge`, `www-data`). In Docker, run cron in a container with your code and `.env`, or run `php artisan schedule:work` as its own process.

### A stuck `withoutOverlapping` lock

`withoutOverlapping()` holds a cache lock while the task runs. If the process dies hard (OOM kill, reboot, a container stopped mid-run), the lock stays for 24 hours by default, and the task is skipped until then.

We reproduced it by killing `schedule:run` with `SIGKILL` while `reports:send` ran. The next minute:

```text
 INFO No scheduled commands are ready to run.
```

`reports:send` was due, and skipped. Clear the lock, and give it a shorter expiry in minutes:

```bash
php artisan schedule:clear-cache
```

```php
Schedule::command('reports:send')->everyMinute()->withoutOverlapping(10);
```

### `onOneServer` without a shared cache

`onOneServer()` needs a `database`, `memcached`, `dynamodb` or `redis` cache that all servers share. Otherwise each server gets its own lock and the task runs everywhere. Scheduled closures also need a `name()`.

### The timezone or daylight saving time

On the night the clocks change, a task at 02:30 Amsterdam time can run twice or not at all; the Laravel docs advise against timezone scheduling for this reason. Schedule in UTC, or avoid 02:00 to 03:00.

### Maintenance mode, or a paused scheduler

During `php artisan down`, tasks don't run unless they have `evenInMaintenanceMode()`, so a deploy that fails after `down` keeps them all off. `php artisan schedule:pause` stops all tasks until `schedule:continue`, except those with `evenWhenPaused()`.

### It runs, but fails

If a task throws or exits non-zero, `schedule:run` reports the exception, fires `ScheduledTaskFailed` and moves on. On Laravel 13.40 its output still said `DONE` for a command and a closure that threw:

```text
 2026-11-07 23:49:03 Running ['artisan' backup:run] .. 253.60ms DONE
 2026-11-07 23:49:03 Running [broken-closure] .. 0.28ms DONE
```

The task ran, so a "did it run?" check stays green. Tasks due at the same minute also run one after another, so a slow one delays the rest; use `runInBackground()` for long commands.

## Check that tasks run: heartbeats

Give each important task a heartbeat URL from a cron monitoring service, which alerts when a ping is missing or reports failure:

```php
Schedule::command('backup:run')
    ->dailyAt('02:30')
    ->pingBefore('https://HEARTBEAT_HOST/HEARTBEAT_ID/start')
    ->pingOnSuccess('https://HEARTBEAT_HOST/HEARTBEAT_ID')
    ->pingOnFailure('https://HEARTBEAT_HOST/HEARTBEAT_ID/fail');
```

`HEARTBEAT_HOST` and `HEARTBEAT_ID` come from your monitoring service; the `/start` and `/fail` paths differ per service. Success and failure follow the exit code; `thenPing($url)` pings after every run.

You add each heartbeat by hand and update it when the schedule changes. To log failures without a service, listen for the event in `AppServiceProvider::boot()`:

```php
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

Event::listen(function (ScheduledTaskFailed $event) {
    Log::critical('Scheduled task failed', [
        'task' => $event->task->description ?? $event->task->command,
        'exception' => $event->exception->getMessage(),
    ]);
});
```

That catches failures, never missed runs: a task that doesn't run fires no event.

## How Lampwick checks your schedule

The [Lampwick Laravel package](/docs/laravel/) records every scheduled task run with its cron expression and timezone, so there are no heartbeats to configure. **Schedule health** derives each task's schedule from its runs and shows the cron in words, the last and next expected run, and a status.

- Missed: no run within a margin after the expected time (at least 5 minutes). One alert, then "Running again" when it recovers.
- Scheduler not running: one alert for the project instead of one per task.
- Failing: when a task starts to fail, at most once an hour per task.
- Too slow: optional, above a runtime you set per task.

Alerts go out by push and email, and to Slack, Discord, Microsoft Teams or a webhook if you add an [alert channel](/docs/alerts/#alert-channels). A task is known after its first run. Keep `LAMPWICK_SCHEDULED_TASK_SAMPLE_RATE` at 1: missed-run alerts need every run. Mute or forget a task on **Schedule health**, or with `updateSchedule` and `deleteSchedule` in the API.

## Next step

[Connect a Laravel app](/docs/laravel/), let your tasks run once, then open **Schedule health**.
