Cron-Ping
AccediInizia gratis

Monitoring the Laravel scheduler: the single cron that hides every silent failure

Last updated: July 2026 · By Yohann Kipfer, Cron-Ping

In short

Laravel's scheduler runs on one crontab line — * * * * * php artisan schedule:run. Every ->daily(), ->hourly() and ->everyFiveMinutes() task lives inside that single process. If that one line is removed, the container OOM-kills, or a deploy wipes the crontab, nothing runs — and Laravel raises no error, because from its point of view it was simply never called. Put a heartbeat inside the scheduler itself:

// routes/console.php (Laravel 11+)
Schedule::call(fn () => Http::get('https://cron-ping.com/p/TOKEN'))
    ->everyFiveMinutes()
    ->name('cron-ping-heartbeat');

The ping only fires if the scheduler is alive. No ping inside the grace period → you get an email or Slack message. That is the one thing php artisan schedule:list can never tell you: it prints what should run, not what did.

Why `schedule:run` is a single point of failure — and a silent one

The official Laravel setup is one crontab entry on the server, added once and then forgotten:

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

That line wakes Laravel every minute. Laravel reads your schedule definition, works out which tasks are due this minute, and dispatches them. So a hundred scheduled jobs — backups, invoice runs, cache warmers, report emails — all depend on this *one* process being invoked. The scheduler is a dispatcher, not a daemon: it has no memory of missed minutes and no retry.

Now look at the redirect: >> /dev/null 2>&1. It is in Laravel's own documentation, and it throws away all output. That is deliberate — otherwise cron mails you every minute — but it also means that when schedule:run starts failing (a fatal in a service provider, a bad .env, a database that's down at boot), the error goes to /dev/null. The task list looks perfect. Nothing runs.

The failure modes that kill the whole scheduler at once: the crontab line was never re-added after a server rebuild; a deploy tool rewrote the crontab and dropped it; the Docker/Kubernetes container running schedule:work was OOM-killed and the orchestrator didn't restart it; PHP threw a fatal before Laravel could boot. In every case schedule:list still prints your tasks, and every single one is dead.

Step 1 — a global heartbeat inside the scheduler

The strongest signal is a ping emitted by the scheduler itself, so it only arrives when the dispatcher is genuinely running. On Laravel 11 and 12 the schedule lives in routes/console.php:

// routes/console.php  (Laravel 11 / 12)
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schedule;

Schedule::call(fn () => Http::get('https://cron-ping.com/p/TOKEN'))
    ->everyFiveMinutes()
    ->name('cron-ping-heartbeat')
    ->withoutOverlapping();

On Laravel 10 and earlier the same goes in the schedule() method of app/Console/Kernel.php using $schedule->call(...). Create the check with a *5-minute period* and a short grace (2 minutes covers a slow queue). If two consecutive pings are missed, the scheduler is down — not one task, all of them.

Why a call() and not a curl wrapped around schedule:run in the crontab? Because a crontab-level curl fires even when Laravel fatals on boot — it would report green while every task is dead. The ping has to originate from after Laravel has successfully booted and the dispatcher is looping.

Step 2 — per-task monitoring with the native hooks

The heartbeat proves the scheduler runs. It does not prove that your nightly backup, specifically, succeeded. Laravel's scheduler ships *URL-ping hooks* for exactly this — no package, they use Guzzle which Laravel already depends on:

Schedule::command('backup:run')
    ->dailyAt('02:30')
    ->withoutOverlapping()
    ->pingOnSuccess('https://cron-ping.com/p/BACKUP_TOKEN')
    ->pingOnFailure('https://cron-ping.com/p/BACKUP_TOKEN/fail');

The full set of hooks, straight from Illuminate\Console\Scheduling:

Laravel scheduler task hooks and what they do
HookFiresUse for
->pingBefore($url)just before the task startsa /start ping to measure duration
->thenPing($url)after the task finishes (any outcome)a plain success heartbeat
->pingOnSuccess($url)only if the task exits 0confirm the job actually worked
->pingOnFailure($url)only if the task exits non-zerohit the /fail endpoint
->pingBeforeIf($cond,$url)before, if condition is trueping only on the prod box
->before() / ->after()run a closure around the taskcustom logging / metrics
->onSuccess() / ->onFailure()closure on the outcomenotify a specific channel

Pair them with the three Cron-Ping endpoints: ->pingBefore() on /p/TOKEN/start (starts the clock), ->pingOnSuccess() on /p/TOKEN (stops it, records the run), ->pingOnFailure() on /p/TOKEN/fail (alerts even though the scheduler itself is fine). Now a backup that runs but throws halfway is caught — the heartbeat alone would never have noticed.

Step 3 — catch a scheduler that died on the last deploy

The most common way a Laravel scheduler dies is a deployment, and each host fails differently:

A heartbeat with a tight grace turns all three into an email within minutes of the deploy, instead of a discovery days later when someone asks why the Monday report never arrived.

The traps: `withoutOverlapping`, sub-minute tasks, and the queue

`withoutOverlapping()` can lock itself out

withoutOverlapping() takes a lock so a long task can't stack on top of itself. But if the process is kill -9'd or the container dies mid-run, the lock isn't released. Laravel expires it after *24 hours* by default — so a task can silently skip for a full day. Pass an explicit expiry (->withoutOverlapping(10) = 10 minutes) so a stuck lock self-heals, and let the /fail ping tell you it happened.

`->everyFiveMinutes()` still needs the minute cron

Sub-minute and frequent helpers (everyMinute, everyFiveMinutes, everyThirtySeconds) are not magic — they are still evaluated by the once-a-minute schedule:run. everyThirtySeconds() works only because schedule:run sleeps and re-checks within the minute. If the master cron isn't firing, none of these run, however frequent they claim to be.

`queue:work` is a different process — don't confuse the two

The scheduler dispatches scheduled tasks. Queued jobs (ShouldQueue, dispatch()) are handled by a *separate long-running worker (`queue:work` / `queue:listen`, usually under Supervisor or Horizon). A dead scheduler and a dead queue worker are two independent outages with two independent symptoms. If your `->daily()` task itself dispatches a queued job, you need both* alive — monitor the scheduler with a heartbeat here, and watch the queue worker separately.

FAQ

How do I know if the Laravel scheduler is actually running?

php artisan schedule:list only prints what's defined, not what ran. The real test: add a Schedule::call() that pings a monitoring URL every few minutes. If the ping stops arriving, the scheduler is down. Checking crontab -l for the schedule:run line and systemctl status cron tells you the OS side; the heartbeat tells you Laravel actually booted and looped.

Where do I put the schedule in Laravel 11 and 12?

In routes/console.php, using the Schedule facade — Schedule::command('...')->daily(). The old app/Console/Kernel.php with a schedule() method still works if you kept it, and is the only option on Laravel 10 and earlier. Both drive the same schedule:run.

What's the difference between schedule:run and schedule:work?

schedule:run evaluates the schedule once and exits — that's the command the crontab calls every minute. schedule:work is a foreground loop that calls schedule:run every minute itself, so you don't need a crontab. It's what you run inside a Docker container. Either way, if the process isn't alive, nothing is dispatched.

Do the ping hooks (pingOnSuccess) cost a package or extra config?

No. pingBefore, thenPing, pingOnSuccess and pingOnFailure are built into the scheduler and use Guzzle, which Laravel already requires. You pass a URL; Laravel fires a GET when the hook triggers. No queue, no package, no scheduled-task config.

My scheduler works locally but not in production after deploy — why?

On Forge, check the site's Scheduler toggle re-added the cron; on Docker, check the schedule:work container restarted and isn't OOM-killed; on any host, run php artisan schedule:run by hand and read the output — the crontab hides it in /dev/null. A heartbeat check with a 5-minute period would have emailed you the moment the deploy broke it.

Know within 5 minutes when your scheduler dies

Add one Schedule::call() heartbeat and per-task ->pingOnFailure() hooks. If schedule:run stops — a wiped crontab, an OOM-killed container, a broken deploy — Cron-Ping emails or Slacks you instead of you finding out when the Monday report never sends.

Create a free check

Free plan: 10 checks, email alerts, 7-day history. No card. EU-hosted.

Related

Monitoring Supabase cron (pg_cron + Edge Functions)The same silent-scheduler problem on Supabase — a ping from pg_net or the Edge Function itself.Alert when a backup stops runningApply the dead man's switch to the one job you only notice when it's too late.Monitoring a Linux cron job that stoppedThe OS layer: crond, /var/mail, and a crontab that got clobbered.Cron-Ping documentationSuccess, start and fail pings, curl/wget/PHP snippets, grace periods.

Sources