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.

Updated View as Markdown

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.

How the scheduler runs

You define tasks in routes/console.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:

* * * * * 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:

 * * * * * 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:

 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:

php artisan schedule:clear-cache
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:

 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:

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():

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 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. 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, let your tasks run once, then open Schedule health.