Get started
← Blog

Your Laravel scheduler failed at 3 AM. When would you find out?

Every Laravel app accumulates scheduled jobs: backups, report emails, cache warmups, cleanup tasks, data syncs. And every one of them fails the same way — silently. schedule:run exits 0 whether your backup ran or not. If the server’s cron entry disappears, if the job throws, if it hangs on a lock — nothing happens. No error page, no failed request, no log entry you’d ever look at. The absence of a thing is invisible.

The classic story: a backup job dies in March, and you learn about it in September, on the day you actually need the backup.

What Laravel gives you out of the box

Laravel is honest about this problem — the scheduler has hooks for it:

$schedule->command('backup:run')
    ->daily()
    ->onFailure(fn () => /* notify someone */)
    ->thenPing('https://example.com/ping/backup');

Two limitations, though.

First, onFailure only fires when the task runs and fails. It cannot tell you about the task that never started: crontab wiped by a server migration, a supervisor that stopped restarting, a deploy that dropped routes/console.php changes. The most dangerous failure mode is the one where your code never executes — and in-process hooks can’t see it by definition.

Second, thenPing solves that (it’s a dead man’s switch: an external service alerts you when pings stop arriving), but it’s manual. You create a check in some dashboard, copy its URL, paste it into the code, and repeat for every task. Twenty tasks, twenty copy-pastes. Then someone changes daily() to twiceDaily() in code and forgets to update the expected schedule in the dashboard — now you get false alerts, or worse, none.

One line per task, schedule included

This is the problem CronAlive was built around. Install the SDK:

composer require cronalive/laravel

Two env vars:

CRONALIVE_PING_DOMAIN=https://ping.cronalive.com
CRONALIVE_PING_KEY=<your project ping key>

And then, per task, one method call — no dashboard round-trip:

$schedule->command('backup:run')
    ->dailyAt('03:30')
    ->pingCronaliveSlug('backup', graceSec: 1800, tags: ['backups', 'prod']);

On the first ping the check creates itself, and — this is the part that matters — it reads the schedule off your scheduled task. ->dailyAt('03:30') becomes cron=30 3 * * *, the task’s timezone travels with it, graceSec sets how late a ping may be before alerting, tags group checks on the dashboard. The schedule lives in your code and only in your code; the monitoring can’t drift out of sync with it, because it was never retyped anywhere.

Your taskWhat the check learns
->everyFiveMinutes()cron=*/5 * * * *
->dailyAt('03:30')->timezone('Europe/Berlin')cron=30 3 * * *, tz=Europe/Berlin
->everyThirtySeconds()period=60 (see gotchas)

The macros also signal the run itself: /start before the job, and after it either a success ping (the plain check URL — success is the default case, so it has no suffix) or /fail. That gets you three things for free: alerts on jobs that started but never finished (hung on a lock, killed by OOM), alerts on jobs that exited non-zero, and a run-duration chart per job — so you notice the backup that quietly went from 4 minutes to 40 before it becomes the backup that overlaps its next run.

Pings are fire-and-forget and never throw. If the monitoring service is unreachable, your backup still runs — monitoring must not break the thing it monitors.

A real production example

Our own dogfood project runs 47 scheduled tasks through this — invoicing, exports, queue maintenance, certificate renewals. The whole migration was one method call per task in Kernel.php, and the checks appeared on the dashboard by themselves on the next scheduler tick, each with its own cron expression, timezone and grace already filled in.

Since then it has caught exactly the failures the in-process hooks can’t: a task that stopped being scheduled at all after a refactor, and a nightly job that hung on a stale cache lock — visible as a /start with no finish, alert in Telegram before breakfast.

One of those tasks is the certificate renewal, and it marks the limit of what heartbeats can tell you. A green check proves the renewal ran — not that the new certificate ever reached the edge. A reload that quietly failed leaves you with a happy scheduler and a site that stops loading for everyone in three weeks. Same shape as the original problem, one level up: the job succeeded, the outcome is false.

That question is answered from the outside — an HTTP check against the public URL, run from several regions, watching the status code, the response time and the expiry date of the certificate that is actually on the wire. It is the natural other half of job monitoring: heartbeats watch the work, HTTP checks watch the result. Both live on the same dashboard here, which is mostly a comfort at 3 AM — one place to look, one set of alert channels to keep configured.

Gotchas worth knowing

Creating is not updating. Schedule parameters apply only when the check is first created. After that, pings just count — a deploy can never silently rewrite a schedule someone tuned in the dashboard. Changed the schedule in code? Update the check in the dashboard, or delete it and let the next ping recreate it.

->when() / ->skip() filters stop the pings too, so a skipped run looks exactly like a missed one. Pause the check for expected skip windows, or move the condition inside the job.

Sub-minute tasks (everyThirtySeconds() and friends) are monitored as “pings at least once a minute” — Laravel keeps the real interval outside the cron expression, and CronAlive’s shortest period is 60 seconds. True for such a job, and it never false-alarms.

Grace has a floor of 30 seconds. Schedulers start jobs with a few seconds of jitter; a tighter grace would turn that jitter into false alerts, so anything lower is raised to 30 rather than refused.

Alternatives

Fairness section. Healthchecks.io is the best-known dead man’s switch — clean, open source, self-hostable; you’ll be managing check URLs by hand or writing your own provisioning against its API, and uptime monitoring for HTTP endpoints is out of scope. Cronitor and Dead Man’s Snitch are solid too, with the same manual-wiring caveat for Laravel. If you only have two or three tasks and don’t mind the copy-paste, thenPing plus any of them is a perfectly good setup.

CronAlive’s angle is the Laravel-native auto-provisioning above, plus the HTTP and certificate side in the same dashboard, and alerts via Telegram, Slack, Discord, email or webhooks. The free plan covers ten checks with no card — enough to wire up the jobs you actually worry about and see which of them have been failing.

Questions, bug reports, scheduler war stories — write to support@cronalive.com. If your scheduler has ever been dead for months without anyone noticing, we would like to hear how you found out.


CronAlive monitors cron jobs, scheduled tasks and HTTP endpoints: if a job stops pinging on schedule, you get an alert. Start with the guide or see the plans.