To monitor the Laravel scheduler, give each scheduled task its own heartbeat URL and ping it from the task with pingOnSuccess(). If the task fails, overlaps, or never starts because the schedule:run cron entry is gone, the ping does not arrive and you get an alert. The trap is thenPing(), which most tutorials show first: it pings after every run, including the failed ones.

I'm Léo, I build Hyperping, and this guide covers the ways the Laravel scheduler fails without an error, what Laravel's own tools show you, and the exact setup I use with Hyperping healthchecks. Every ping method below was tested on Laravel 13.35 (the current release as of October 2026) against a local HTTP listener.

Key takeaways

  • The whole Laravel scheduler hangs off one crontab line, * * * * * php artisan schedule:run. Lose it in a server migration and every task stops, with nothing in the logs.
  • thenPing() fires whatever the exit code. Use pingOnSuccess() so a failed task stays silent and the missed ping raises the alert.
  • pingOnSuccess() works for Schedule::call closures (exit code 1 when they throw) and for runInBackground() tasks (through schedule:finish).
  • A withoutOverlapping() lock left behind by a crash skips the task for 24 hours by default, and maintenance mode or a wrong APP_ENV skips it without even an event.
  • Monitor from outside the scheduler: one healthcheck per task in cron mode with the same expression and timezone, plus a five-minute heartbeat for the scheduler itself.

How the Laravel scheduler fails silently

Laravel's scheduler is a single Artisan command, schedule:run, that cron starts every minute. It checks which tasks are due, runs them, and exits. Most of what goes wrong outside a task's own code produces no error at all.

The cron entry disappears

The Laravel scheduling docs ask for exactly one cron entry:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Nothing in Laravel checks that this line exists. Move the app to a new server, rebuild the Docker image without a cron daemon, or install the crontab under a user that cannot read .env, and no task runs again. The output goes to /dev/null, so there is not even a log line to find later.

thenPing reports failures as successes

Laravel has three pairs of ping methods, defined in Event.php:

Method When it sends the GET request
pingBefore($url) Before the task runs
thenPing($url) After the task, whatever the exit code
pingOnSuccess($url) After the task, only if the exit code is 0
pingOnFailure($url) After the task, only if the exit code is not 0

The ...If($condition, $url) variants add a condition. I ran a command that returns 1 with all of them attached: the listener received pingBefore, thenPing and pingOnFailure, and no pingOnSuccess. A monitor fed by thenPing would have marked that run as healthy.

A failed ping never breaks the task: Laravel sends it through Guzzle with a 10 second connect timeout and a 30 second timeout, reports any exception to your exception handler, and moves on.

Overlap locks outlive a crash

withoutOverlapping() stores a lock in the cache while the task runs. The docs say "By default, the lock will expire after 24 hours". If the server dies mid-run, or the process is killed with SIGKILL, the lock stays and every following run is skipped until it expires or you run php artisan schedule:clear-cache. A skipped run dispatches a ScheduledTaskSkipped event and writes nothing to the log.

Pass a shorter expiry in minutes, sized to the task: ->withoutOverlapping(120).

onOneServer needs a shared cache

onOneServer() stops a task from running on every server behind your load balancer. The docs require the database, memcached, dynamodb or redis cache driver as the default, shared by all servers. With the file driver on each box, every server takes its own lock and the task runs once per server. Point SCHEDULE_CACHE_STORE at a shared store if your default cache is local.

Maintenance mode, environments and DST skip runs

Three filters make a task "not due" with no event at all:

  • php artisan down: "scheduled tasks will not run when the application is in maintenance mode" unless you add ->evenInMaintenanceMode(). If a deploy script fails before php artisan up, the nightly jobs stop with it.
  • ->environments(['production']) with an APP_ENV of prod on the new server.
  • Daylight saving time. The docs warn that with a DST timezone "your scheduled task may run twice or even not run at all". Keep critical tasks out of the 1:00 to 3:00 window, or schedule them in UTC.

Errors go to the exception handler only

A task that throws or exits non-zero is reported to your exception handler. If that handler writes to a log file nobody reads, the failure is recorded and nobody hears about it. For runInBackground() tasks, the ScheduledTaskFailed event is not dispatched at all.

How to check the scheduler with Laravel's own tools

Laravel gives you a decent view from inside the app:

# Every task, its expression and the next due date
php artisan schedule:list

# Same list, with times shown in the task's timezone
php artisan schedule:list --timezone=Europe/Paris

# Run one task now, outside its schedule
php artisan schedule:test --name=backup:run

# Run the scheduler in the foreground every minute (local development)
php artisan schedule:work

For output and failures, ->appendOutputTo(storage_path('logs/backup.log')) keeps the output of command and exec tasks, ->emailOutputOnFailure('ops@example.com') mails it when the exit code is not 0, and ->onFailure(fn () => ...) runs your own code.

All of these run inside schedule:run. If cron never starts it, no hook fires, no email goes out, and schedule:list still shows a cheerful "Next Due: 4 hours from now". That is the case an external heartbeat covers.

How to monitor the Laravel scheduler with Hyperping

A Hyperping healthcheck is a secret URL that expects a request on a schedule. When the request does not arrive by the expected time plus a grace period, it opens an incident and alerts you, then resolves on the next successful ping. Healthchecks are included on every plan, Free included.

1. Create one healthcheck per scheduled task

In Hyperping, open Healthchecks, click Create healthcheck, name it after the task, and pick Cron. Use the same expression and the same timezone as the Laravel task:

Laravel frequency Cron expression Generator page
->everyFiveMinutes() */5 * * * * every 5 minutes
->hourly() 0 * * * * every hour
->daily() 0 0 * * * every day
->dailyAt('02:00') 0 2 * * *
->weeklyOn(1, '8:00') 0 8 * * 1

For the timezone, use the task's ->timezone() if it has one, otherwise app.schedule_timezone, otherwise app.timezone (UTC in a fresh app). Do not copy the expression from schedule:list: it converts every expression to the display timezone, so dailyAt('02:00')->timezone('Europe/Paris') is listed as 0 0 * * * in summer. Hyperping applies the timezone itself, DST included.

Each healthcheck gives you a URL like https://hc.hyperping.io/tok_....

2. Store the ping URLs in config, not in env() calls

Calling env() from routes/console.php returns null once you run php artisan config:cache in production, and the ping silently goes nowhere. Put the URLs in config/services.php:

// config/services.php
'hyperping' => [
    'backup' => env('HYPERPING_BACKUP_URL'),
    'invoices' => env('HYPERPING_INVOICES_URL'),
    'scheduler' => env('HYPERPING_SCHEDULER_URL'),
],
# .env
HYPERPING_BACKUP_URL=https://hc.hyperping.io/tok_your_backup_token
HYPERPING_INVOICES_URL=https://hc.hyperping.io/tok_your_invoices_token
HYPERPING_SCHEDULER_URL=https://hc.hyperping.io/tok_your_scheduler_token

3. Ping on success only, with /start for duration

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:run')
    ->dailyAt('02:00')
    ->timezone('Europe/Paris')
    ->onOneServer()
    ->withoutOverlapping(120)
    ->pingBefore(config('services.hyperping.backup').'/start')
    ->pingOnSuccess(config('services.hyperping.backup'));

Schedule::command('invoices:send')
    ->hourly()
    ->runInBackground()
    ->pingOnSuccess(config('services.hyperping.invoices'));

pingBefore hits /start, which opens a run in Hyperping without moving the deadline. pingOnSuccess closes it, records how long the task took, and sets the next deadline. A failed run sends nothing after /start, so the healthcheck goes down once the grace period runs out.

With onOneServer(), only the server that wins the lock sends pings, which is what you want. The runInBackground() task pings too: Laravel appends php artisan schedule:finish to the command, and that process sends the ping with the real exit code. I checked both on Laravel 13.35 with config:cache enabled.

4. Add a heartbeat for the scheduler itself

A weekly task only notices a missing cron entry a week later. Add a no-op task that pings its own healthcheck every five minutes:

Schedule::call(fn () => null)
    ->name('scheduler-heartbeat')
    ->everyFiveMinutes()
    ->pingOnSuccess(config('services.hyperping.scheduler'));

Create its healthcheck in simple mode, every 5 minutes, with a 5 minute grace period. If cron stops, PHP fails to boot after a bad deploy, or the app is left in maintenance mode, you know within about ten minutes. Leave onOneServer() off this one if you want to hear about a single server losing its crontab.

5. Size the grace period to the task's duration

In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period. The success ping is sent when the task ends, so the grace period has to cover the run time. A backup that takes 25 minutes at 02:00 needs at least 30 minutes, and I would give it 45.

After a few runs, the healthcheck's ping history shows the duration between each /start and success ping. Set the grace period to roughly twice the longest one you see. The default is 10 minutes, and the minimum is 1.

6. Route the alerts

A missed ping goes to every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies or on-call schedules, so if a failed backup has to wake someone at night, send the alert to PagerDuty or Opsgenie and let their rotation decide who gets paged.

Test it before you trust it

Run php artisan schedule:test --name=backup:run and check that the ping appears in the healthcheck's last pings, with GuzzleHttp as the user agent. Then make the command return Command::FAILURE on staging and wait: no success ping arrives, and the alert should reach you once the grace period passes. Fix it, run it again, and the incident closes on the next ping.

If you schedule jobs outside Laravel too, the same pattern works for Kubernetes CronJobs, GitHub Actions scheduled workflows, systemd timers and Windows Task Scheduler. For a side by side of heartbeat tools, see the best cron job monitoring tools. Other frameworks have their own guides: Rails scheduled jobs, Node.js cron jobs and Celery beat.