Never miss a cron again: monitoring Laravel scheduled tasks
Laravel cron monitoring with heartbeats and missed-run detection, so a scheduled task that silently stops running pages you before anyone notices.
Scheduled tasks are the most dangerous code you have, because when they stop, nothing happens — literally. No error, no 500, no angry user in the moment. The nightly billing run just... does not run. The weekly digest never sends. The cleanup job stops cleaning and the disk fills up three weeks later. Laravel cron monitoring exists for exactly this failure mode: detecting the absence of work. This post explains why schedulers fail silently and how to get alerted when a job doesn't run.
Why schedulers fail without a sound
A normal exception is loud — it throws, it gets reported, someone sees it. A missed scheduled run is the opposite: there is no exception because there is no execution. Common causes:
- The system crontab entry for
schedule:runwas removed or never deployed - The server was rotated and the cron was not re-added
- An earlier task in the same minute hung and blocked the rest
- A deploy changed the command and it now errors before your task starts
In every case, the symptom is silence. You cannot catch an error that never fires.
The fix: heartbeats
The trick is to flip the problem around. Instead of waiting for a failure, you expect a success signal on a schedule. The task "checks in" each time it runs. If a check-in does not arrive within the window you expect, that absence is the alert.
This is called a heartbeat (or dead-man's switch). A daily job that normally checks in at 02:00 should be flagged if 02:00 comes and goes with no signal. You are no longer monitoring for errors — you are monitoring for the expected pulse.
Wire it into Laravel's scheduler
Laravel's scheduler has hooks for exactly this. Use before, after, and failure callbacks to send a check-in when a task starts and finishes:
Schedule::command('billing:run')
->dailyAt('02:00')
->before(fn () => Log::info('billing:run started'))
->after(fn () => Log::info('billing:run finished'))
->onFailure(fn () => Log::error('billing:run failed'));
Now each run leaves a trail. Pair that with a monitor that knows the job should report finished every day, and a missing "finished" becomes a page.
Detect the missed run, not just the failed one
Logging start and finish is half the job. The other half is something watching for the gap. With Lookout installed, you send the check-ins over plain HTTP/JSON and configure the expected cadence; if a scheduled task misses its window, you get alerted to Email or Slack — the same place your errors and queue alerts already go.
Install is two commands:
composer require lookout/tracing
php artisan lookout:install
LOOKOUT_LARAVEL=true
LOOKOUT_API_KEY=your-project-api-key
LOOKOUT_URL=https://your-lookout-host.example.com
The point is coverage of the absence case. A failed run reports an error; a missed run reports nothing — and only a heartbeat-style check catches that second one.
Prioritize what actually matters
You do not need a heartbeat on every cron. Start with the tasks where silence is expensive:
- Billing and invoicing runs
- Anything that emails customers (digests, reminders, renewals)
- Data exports and backups
- Cleanup jobs that prevent disk or quota problems
Add check-ins to those first. Each one you cover removes a category of "we found out three weeks later" incidents.
Scheduled tasks earn their keep by being invisible — until the day their silence costs you. Heartbeat monitoring is the cheap insurance that turns "the billing job stopped last Tuesday" into a same-day alert. Lookout's free Starter tier (10,000 events a month, one project, no credit card) is enough to put heartbeats on your critical jobs today. Set up cron monitoring for free and stop trusting silence.