Laravel's scheduler is one of the framework's best features: all your periodic work declared in code, driven by a single cron entry running php artisan schedule:run every minute. It's also one of the framework's quietest failure modes, because that design concentrates every scheduled task behind one cron line — and when that line goes missing, everything stops at once, with no errors, no log entries, and nothing in your exception tracker. Report generation, cache warming, subscription billing, cleanup jobs: all silently frozen until a customer asks why their invoice never arrived.

Here's the part that surprises people: Laravel already has heartbeat monitoring built into the scheduler API. The pingOnSuccess, pingOnFailure, pingBefore, and thenPing hooks have been sitting in the docs for years, ready to fire an HTTP request to any URL after a task runs. What Laravel doesn't provide is the other half — something that notices when the ping doesn't arrive. That's what a heartbeat monitor is, and wiring the two together takes a few minutes. This post covers the failure modes worth catching and the two-layer setup that catches them, joining our guides to Celery Beat, systemd timers, and plain cron.

How the Laravel scheduler fails — a field guide

  • The cron entry vanishes. A server migration, a rebuilt container image, a new deployment target that never got the crontab line. The scheduler isn't broken — it's simply never invoked. This is the most common cause of "every scheduled task stopped on the same day."
  • Maintenance mode eats your tasks. By default, scheduled tasks do not run while the app is in maintenance mode. A php artisan down that was supposed to last ten minutes and got forgotten overnight silently skips every run. (Opt out per-task with evenInMaintenanceMode() — but only for tasks that are actually safe to run mid-maintenance.)
  • A stuck withoutOverlapping lock. The overlap guard is a cache-based mutex. If a run dies hard — server reboot, OOM kill, deploy that terminates PHP mid-task — the lock can be left behind, and every subsequent run is skipped as an "overlap" until the lock expires (24 hours by default). One crash becomes a day of silence. php artisan schedule:clear-cache clears stuck locks.
  • The queued-job dispatch gap. Schedule::job(new GenerateInvoices) doesn't run the job — it dispatches it. The scheduler's job is done the moment the payload lands on the queue, so the schedule "succeeds" even when every queue worker is dead and the job never executes. Success hooks on the scheduled task can't see past the dispatch.
  • DST does DST things. Timezone-aware schedules can run twice or not at all on daylight-saving transition days — Laravel's own docs warn about this for tasks scheduled inside the shifted window.
  • onOneServer without a shared cache. Single-server guarantees require a cache all servers share (Redis, Memcached, database). With per-server file caches, either every server runs the task or — after a config change — none does.
  • The task itself throws. The only failure mode most teams instrument. Exception trackers catch it; everything above this line they don't.

Notice the pattern: six of these seven produce no error anywhere. That's why the fix is silence-based — instead of waiting for an error that will never fire, you expect a positive signal on a schedule and alert when it goes missing.

The built-in hooks: one method call per task

Create a heartbeat monitor in CronAlert (Monitors → New → Heartbeat), set the expected interval to match the task's schedule plus a grace period, and chain the hook onto the task. In Laravel 11+ this lives in routes/console.php; in older versions, in app/Console/Kernel.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('invoices:generate')
    ->dailyAt('02:00')
    ->withoutOverlapping()
    ->pingOnSuccess('https://cronalert.com/api/heartbeat/YOUR_TOKEN')
    ->onFailure(function () {
        // optional: also notify on explicit failure
    });

pingOnSuccess fires only when the command exits cleanly — which is exactly what you want. A task that crashes doesn't ping, a task that's skipped by a stuck lock doesn't ping, a task on a server that lost its cron entry doesn't ping. In every case CronAlert notices the missing beat and alerts you within the grace period, without your app having to know what went wrong. (Older Laravel versions require Guzzle for the ping hooks: composer require guzzlehttp/guzzle.)

Two hook-choice rules from the field. First, prefer pingOnSuccess over thenPing for real work — thenPing fires after the task finishes regardless of outcome, which turns your heartbeat into a liveness signal that happily reports a task that fails every night. Second, resist pingOnFailure as your only wire: it catches thrown exceptions, but the field guide above is a list of ways tasks fail without throwing anything.

The scheduler-liveness canary

Per-task heartbeats tell you a given task is healthy. They don't cleanly answer the sharper question during an incident: is the scheduler running at all? For that, add one trivial task whose only job is to prove the machinery works:

Schedule::call(fn () => true)
    ->everyFiveMinutes()
    ->thenPing('https://cronalert.com/api/heartbeat/CANARY_TOKEN');

Point it at a heartbeat expecting a ping every 5 minutes with a couple of minutes' grace. A live canary plus a silent task heartbeat means that task is the problem (stuck lock, crash, maintenance-mode skip). A silent canary means the whole chain is down — cron entry, PHP, app bootstrap, or the scheduler itself — and every task with it. Here thenPing is the right hook, since the canary has no meaningful failure of its own. This is the same beat-liveness pattern we use for Celery Beat, and it's the single highest-value monitor in this post: one heartbeat that converts "the cron line silently vanished" from a customer report into a five-minute alert.

Closing the dispatch gap for queued jobs

For Schedule::job() tasks, move the ping inside the job so it fires on execution, not dispatch:

use Illuminate\Support\Facades\Http;

class GenerateInvoices implements ShouldQueue
{
    public function handle(): void
    {
        // ... the actual work ...

        Http::get('https://cronalert.com/api/heartbeat/INVOICES_TOKEN');
    }
}

Now the beat proves the full chain: scheduler fired, queue accepted the payload, a worker picked it up, and the work finished. A dead worker pool surfaces as a missed beat instead of a quietly growing queue. (Ping at the end of handle(), after the work — a ping at the top reports jobs that start and die.) For the worker-pool side of this story — Horizon, supervisor configs, and queue-depth signals — see background worker monitoring.

Set it up in ten minutes

  • 1. Create the canary heartbeat. In CronAlert, add a heartbeat monitor: expected every 5 minutes, grace 2 minutes. Add the everyFiveMinutes() canary task with thenPing.
  • 2. Add per-task heartbeats for the money paths. Billing, reports, data syncs — anything whose silent failure costs real money. pingOnSuccess for commands, in-job pings for queued work. Match each monitor's expected interval to the task's schedule (daily task → expected every 24 hours, grace 30–60 minutes).
  • 3. Verify with schedule:list. Confirm what's actually registered and when it runs next — config drift between environments shows up here.
  • 4. Route the alerts somewhere humans look. Slack or Discord channels work on the free plan; Pro adds Teams, Telegram, PagerDuty, and push. Then fire-drill it: comment out the canary's cron line in staging and confirm the alert arrives.

Heartbeat monitors are part of CronAlert Pro ($5/mo, $4 annual), which covers 100 monitors — enough for a canary plus every scheduled task and the HTTP side of your Laravel app with room to spare.

Frequently asked questions

How do I know if the scheduler is running at all?

The liveness canary: a trivial everyFiveMinutes() task with thenPing. Silence within minutes of the chain breaking, no matter which link broke.

Does Laravel have this built in?

Half of it — the ping hooks. It has no receiver that notices missing pings; that's the heartbeat service's job.

Why did my task stop with no errors?

Missing cron entry, forgotten maintenance mode, a stuck withoutOverlapping lock, or a dead queue worker behind Schedule::job(). All error-free by design — which is why you monitor for silence, not errors.

Should I use spatie/laravel-schedule-monitor instead?

Use it as well if you want per-run history in your database — it's excellent for debugging. But it lives inside the app it watches: when the scheduler or the database is what died, only an external heartbeat is still standing.

One cron line, zero blind spots

The Laravel scheduler's single-entry-point design is a feature right up until that single point fails silently. The fix costs almost nothing: one canary task with thenPing, pingOnSuccess on the tasks that matter, and in-job pings for queued work. Laravel wrote its half of the integration years ago — create a free CronAlert account, add a heartbeat monitor, and wire up the other half this afternoon.

Related reading: Uptime monitoring for Laravel applications, cron job heartbeat monitoring, background worker monitoring, and the rest of the scheduler family: Celery Beat, systemd timers, Kubernetes CronJobs, GitHub Actions schedules, and Heroku Scheduler.