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. UsepingOnSuccess()so a failed task stays silent and the missed ping raises the alert.pingOnSuccess()works forSchedule::callclosures (exit code 1 when they throw) and forrunInBackground()tasks (throughschedule:finish).- A
withoutOverlapping()lock left behind by a crash skips the task for 24 hours by default, and maintenance mode or a wrongAPP_ENVskips 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>&1Nothing 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 beforephp artisan up, the nightly jobs stop with it.->environments(['production'])with anAPP_ENVofprodon 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:workFor 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_token3. 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.
FAQ
Why is my Laravel scheduled task not running? ▼
Start with the cron entry: the scheduler only runs when `* * * * * cd /path && php artisan schedule:run` fires every minute, and a server migration or a container without cron drops it without any error. Then run `php artisan schedule:list` to see the next due date, check that the app is not in maintenance mode, and that `environments()` matches `APP_ENV`. A stale `withoutOverlapping` lock also skips a task silently until `php artisan schedule:clear-cache` removes it.
What is the difference between thenPing and pingOnSuccess in Laravel? ▼
`thenPing` sends its request after every run, whatever the exit code, so a failed task still looks healthy to your monitor. `pingOnSuccess` only sends it when the task exits with code 0, and `pingOnFailure` only when it does not. I tested both on Laravel 13.35: a command returning 1 fired `thenPing` and `pingOnFailure`, never `pingOnSuccess`.
Does pingOnSuccess work with Schedule::call closures? ▼
Yes. Laravel gives a closure an exit code of 1 when it throws or returns false, and 0 otherwise, so `pingOnSuccess` and `pingOnFailure` work on `Schedule::call`. For `Schedule::job`, success only means the job was pushed to the queue, not that a worker ran it.
Does the Laravel scheduler send pings with runInBackground? ▼
Yes. A background task appends `php artisan schedule:finish` to its shell command, and that second process runs the after hooks, including `pingOnSuccess` and `pingOnFailure`, with the real exit code. The `ScheduledTaskFailed` event is not dispatched for background tasks, so do not rely on a listener for those.
Which timezone should the Hyperping healthcheck use for a Laravel task? ▼
The one the task runs in: the value passed to `->timezone()`, otherwise `app.schedule_timezone`, otherwise `app.timezone`, which is UTC in a fresh Laravel app. Do not copy the expression from `php artisan schedule:list`: it converts every expression to the display timezone, so `dailyAt('02:00')->timezone('Europe/Paris')` shows up as `0 0 * * *` in summer.
How do I get alerted when the Laravel scheduler stops entirely? ▼
Schedule a no-op closure every five minutes with `pingOnSuccess` to its own healthcheck. If the cron entry disappears or PHP crashes on boot, that heartbeat stops and you hear about it within minutes instead of waiting for the next weekly task to miss its run.




