At 2:00 a.m. your Laravel scheduler was supposed to run invoices:send. It didn’t, and nothing anywhere recorded that fact. That’s not a bug. The crontab entry the Laravel docs tell you to install ends in >> /dev/null 2>&1, which throws away every line of output on purpose. Silent is the documented default. Getting un-silent takes about ten minutes, so let’s do that first.
The Fastest Fix: An External Ping on Every Job That Matters
The reliable way to monitor Laravel scheduled tasks is a dead man’s switch: your job tells an outside service “I ran,” and the outside service alerts you when the message stops arriving. The alert fires on absence, so it catches every failure mode at once, including the ones where your whole server is the problem.
Create a cron monitor. In FlareWarden, add a cron monitor with a name, the expected schedule (a cron expression or interval), and a grace period. You get back a unique ping URL of the form
https://app.flarewarden.com/ping/YOUR-TOKEN. Cron monitoring is included on every plan, free included.Wire the ping into your schedule. Laravel has this built in, no package needed. In
routes/console.php:
Schedule::command('invoices:send')
->dailyAt('02:00')
->pingOnSuccess('https://app.flarewarden.com/ping/YOUR-TOKEN')
->pingOnFailure('https://app.flarewarden.com/ping/YOUR-TOKEN/fail');
pingOnSuccess fires only when the command exits zero; pingOnFailure fires on any non-zero exit, and the /fail endpoint accepts an optional error message in the request body. If you don’t care about distinguishing the two, a single thenPing($url) after the task also works. The cron monitoring docs have copy-paste equivalents for curl, shell scripts, and other frameworks.
- Deploy and confirm the first ping. Run the command once by hand and watch the monitor go green. From there the monitor follows a deterministic state machine and alerts you on missed runs, explicit failures, and hung jobs that blow past their expected duration, with the grace period absorbing normal jitter. A backup that never checks in wakes somebody up.
That’s the whole setup. Now the part worth understanding: why the failures were invisible in the first place.
Why Laravel Jobs Fail Without a Sound
Every one of these is a failure mode we’ve either hit ourselves or seen take out someone’s background jobs for days. Each is invisible to log-watching, because the log line never gets written.
- The crontab entry disappears. The entire scheduler hangs off one line:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1. New server, new deploy pipeline, restored image, forgotten provisioning step, and that line quietly isn’t there anymore. Nothing errors, because nothing runs. - Output is discarded by design. That same line routes stdout and stderr to
/dev/null. Exceptions inside a task don’t reach your terminal, your logs, or you, unless you’ve opted in per task with hooks likeonFailure()oremailOutputOnFailure(), and the email variants only work oncommandandexectasks with mail already configured. - A stuck overlap lock skips runs silently.
withoutOverlapping()prevents two instances running at once by taking a cache lock, and that lock lives for 24 hours by default. If a task dies mid-run and the lock survives, every subsequent run is skipped without a peep until you notice and runschedule:clear-cache. - One slow task delays the rest. Tasks due at the same minute run sequentially unless marked
runInBackground(). A report that starts taking 20 minutes shoves everything scheduled behind it. - Queued jobs only dispatch.
Schedule::job()succeeding means the job entered the queue, nothing more. If your queue worker is down, dispatch keeps “succeeding” while no work happens. The scheduler has no opinion about whether the queue is draining. - Maintenance mode mutes everything. Tasks don’t run during
php artisan downunless markedevenInMaintenanceMode(), andschedule:pausestops the lot until someone remembersschedule:continue.
php artisan schedule:list is the quick audit: it shows every registered task and its next run time. It tells you what should happen, which is exactly why it can’t tell you what did.
Where Spatie’s Schedule Monitor Fits
spatie/laravel-schedule-monitor is the best-known package here, and it’s genuinely useful: it records every start, finish, and failure of your scheduled tasks to two tables in your database, and schedule-monitor:list gives you a color-coded terminal overview with late tasks in red.
Notice what it doesn’t do: send alerts. That’s deliberate, and Spatie’s reasoning is the whole argument of this article. If your scheduler is broken, “a scheduled task that sends out notifications would probably not run either.” Their answer is syncing with an external service (Oh Dear, in their case) that alerts when pings stop. Ours is the same architecture with a different endpoint. The package is a great flight recorder; pair it with an external listener, because a monitor that lives inside the thing it monitors shares every one of its failure modes. We’ve written before about monitoring anti-patterns, and self-monitoring is the classic.
When the Job Runs but Takes Ten Times Longer
There’s a failure mode gentler than “missed” that pure schedule tracking won’t flag: drift. The nightly backup that took 4 minutes in March takes 40 in July. It still completes, still exits zero, still pings success, and it’s quietly heading toward colliding with your morning traffic or filling a disk.
Duration is worth watching alongside presence. FlareWarden’s anomaly detection baselines each cron monitor’s run duration from its own recent completed runs and flags confirmed outliers, which turns “the backup is slowly dying” from a postmortem finding into a Tuesday-morning curiosity. However you monitor Laravel scheduled tasks, track how long they take, not just whether they happened.
What We’d Actually Wire Up
You don’t need a ping on everything. Our rule: an external ping on every scheduled task that moves money or data, and on any task another system depends on. Invoices, backups, subscription renewals, sitemap generation for a client, certificate renewal. Skip the cache warmers and horizon snapshots; if they fail, the symptom shows up elsewhere and the noise costs more than the coverage.
Set grace periods honestly. A nightly job that normally finishes in 5 minutes deserves a 15-minute grace, not 5 — you want the alert to mean something when it arrives. And after you wire it up, kill the job once on purpose and confirm a human actually gets told. An alert path nobody has tested is a wish.
Key Takeaways
- Laravel’s documented crontab line ends in
>> /dev/null 2>&1— failures are silent by default, and per-task hooks likeonFailure()are opt-in - The deadliest failures produce no log line at all — a missing crontab entry, a stuck
withoutOverlappinglock, a dead queue worker behindSchedule::job(), or forgotten maintenance mode - Alert on absence, not on errors — an external dead man’s switch catches every failure mode, including the ones where the server itself is gone
- In-app packages are flight recorders, not alarms — spatie/laravel-schedule-monitor’s own docs route alerting to an external service, because an in-app notifier fails with the app
- Watch duration, not just presence — a job that still succeeds but takes 10× longer is a failure in progress, and baseline-based detection catches it early
- Ping the tasks that move money or data; skip the cache warmers — and test the alert path once, on purpose
Want to know the moment a scheduled job goes quiet? Start monitoring free with FlareWarden — cron and heartbeat monitoring on every plan, 15 monitors, no credit card required.