• 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.

Add your first check free.

Free for 15 checks
No card needed