- Cron jobs
Monitoring the Laravel scheduler: catch tasks that stop running
Laravel runs every scheduled task from one cron entry, so one problem can stop them all. Here's how to monitor each task with Laravel's own pingOnSuccess hook, and the traps to avoid.
Jason
Co-founder, Pingstack
Laravel’s scheduler is a single point of failure by design. Every task in
routes/console.php runs from one cron entry calling schedule:run each
minute. When that entry is missing on a new server, or the container you
deploy to doesn’t run cron at all, every scheduled task stops at once. Laravel
can’t tell you, because nothing is running to notice.
The good news is you don’t need any extra packages to catch it. Laravel’s
built-in pingOnSuccess hook and an outside heartbeat check do the job.
How Laravel scheduled tasks fail silently
The scheduler never runs. The * * * * * php artisan schedule:run line
didn’t make it onto the new server, the deploy moved to containers, or the
schedule:work process under Supervisor died. Every task stops together.
A task throws. The command fails, Laravel logs the exception, and the next run fails the same way. If nobody reads that log channel, the task can stay broken for weeks.
A task is skipped. withoutOverlapping() takes a lock for each run. If the
process is killed mid-run, the lock stays until it expires, 24 hours by
default, and every run until then is skipped without an error.
A task runs but does nothing. The report “sends” to zero recipients, or the cleanup command finds nothing because a config value is empty in production.
Why the Laravel scheduler can’t watch itself
Laravel’s onFailure hooks and exception reporting only fire when a task
runs and fails. They can’t fire when schedule:run itself never starts, and
that’s the failure that takes everything down. What you want is a signal from
outside that has to arrive. Each important task reports success to a
heartbeat check, and the check alerts you when the report is late or missing.
Monitor the Laravel scheduler with pingOnSuccess
1. Create a heartbeat check for the task
Find the task’s schedule. php artisan schedule:list shows each task’s cron
expression and next run time. For example, dailyAt('02:00') is 0 2 * * *.
Create a heartbeat check with that expression.
Laravel runs tasks in your application’s time zone (APP_TIMEZONE, or
timezone in config/app.php) unless a task sets ->timezone(...). Pick the
same time zone on the check, or a task at 02:00 in your app’s zone will look
hours late. The cron expression explainer
shows the next run times for an expression in any time zone.
Set the grace period longer than the task’s slowest normal run.
2. Keep the ping URL in configuration
Anyone with the ping URL can report a run, so treat it like a secret. Put it
in the server’s .env:
PINGSTACK_REPORTS_PING_URL=
Read it through a config file, because env() returns null outside config
files once you run php artisan config:cache. In config/services.php:
'pingstack' => [
'reports_ping_url' => env('PINGSTACK_REPORTS_PING_URL'),
],
3. Ping only on success
In routes/console.php (Laravel 11 and later):
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:send')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer()
->pingOnSuccess(config('services.pingstack.reports_ping_url'));
On Laravel 10 and earlier, use the same chain on $schedule->command(...) in
the schedule() method of app/Console/Kernel.php.
pingOnSuccess sends the request only when the command exits successfully, so
a failed run sends nothing and the check alerts when the deadline passes.
onOneServer() stops several app servers from each running and reporting the
task. It needs a cache driver that supports atomic locks, such as Redis or the
database.
Avoid thenPing here. It pings after every run, including failed ones, so a
broken task would keep reporting as healthy.
4. Test it
php artisan schedule:test --name="reports:send"
The check should show a heartbeat and its next deadline. If nothing arrives,
run php artisan tinker and check that
config('services.pingstack.reports_ping_url') isn’t empty. A stale config
cache is the usual reason.
Watch out for queued jobs
Schedule::job(new SendReports) dispatches the job to your queue. For the
scheduler, success means the job was dispatched, so pingOnSuccess would
report even if the queue worker is down and the job never runs. For queued
work, report from the end of the job’s handle method instead, after the work
succeeds:
use Illuminate\Support\Facades\Http;
Http::timeout(10)->retry(3, 1000)->get(
config('services.pingstack.reports_ping_url'),
);
Bonus: this also catches a dead queue worker, which no scheduler hook can see.
Catch “succeeded but did nothing”
Send a count with the report and set warning and critical thresholds on the
check. Report from the end of the command’s handle method, and remove
pingOnSuccess from that task so it doesn’t report twice:
Http::timeout(10)->retry(3, 1000)->get(
config('services.pingstack.reports_ping_url'),
['value' => $sentCount],
);
A report that sends to zero recipients then raises an alert.
One check per task
Give each task you care about its own check and its own variable. When the whole scheduler stops, every check alerts at once. That tells you straight away the problem is the cron entry or the server, and not one task.
Common questions
Do I need a package to monitor the Laravel scheduler? No.
pingOnSuccess is built into Laravel’s scheduler and uses the HTTP client
that new Laravel apps include.
Does pingOnSuccess work with runInBackground()? Yes. Laravel reports
when the background process finishes, so the ping arrives after the work is
done.
Why does my task alert during deploys? Tasks don’t run in maintenance mode
unless they call ->evenInMaintenanceMode(). A long maintenance window can
push a task past its grace period.
Can I monitor schedule:run itself? Add a cheap task that runs every few
minutes and pings its own interval check. If that check goes quiet, the
scheduler isn’t running at all.
Try it
Pingstack’s cron job monitoring takes each task’s cron expression and your app’s time zone, alerts when a run is missing or late, and tracks a reported number against thresholds. Free covers 15 checks. The full Laravel recipe is in the docs, and the cron monitoring guide covers the general setup. Start free and add a check to the task you’d least like to fail silently.