Laravel's built-in task scheduler lets you define all your scheduled jobs in App\Console\Kernel.php and drive everything from a single crontab entry. It's clean, expressive, and easy to maintain. But it shares the same fundamental weakness as every cron-based system: when it stops working, there is no alert. The logs go quiet. The jobs don't run. And you find out days later when a customer asks why their weekly report never arrived.
The entire Laravel scheduler depends on one crontab entry running every minute on your server:
* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1
When this fires, Laravel checks your Kernel.php schedule definition, determines which commands are due, and runs them. When it doesn't fire — because the server was patched and rebooted, the deploy wiped the crontab, a permissions change broke php, or the path to your app changed — nothing runs and nothing tells you.
php symlink that breaks after upgrading from 8.1 to 8.3 via a package managerartisan — a deploy step runs as root, changes file ownership, and subsequent cron runs as the app user can no longer execute the fileonFailure hookLaravel provides lifecycle hooks for scheduled commands. You can chain onFailure() to send an alert when a command throws an exception:
$schedule->command('reports:generate')
->daily()
->onFailure(function () {
// Send a Slack/email notification
Notification::route('mail', 'team@example.com')
->notify(new ScheduledCommandFailed('reports:generate'));
});
This is useful — but it only catches exceptions thrown inside the command. It does nothing when the scheduler itself stops running. A broken crontab or a missing php binary means schedule:run never executes at all, so no hooks fire.
You can capture command output and monitor for stale log files:
$schedule->command('reports:generate')
->daily()
->appendOutputTo(storage_path('logs/scheduler.log'));
Then watch scheduler.log for recent writes with a file-age alert. This is better than nothing, but it requires another monitoring layer that is itself a single point of failure.
The most reliable approach is a dead man's switch: your scheduled command pings a monitoring URL after it runs successfully, and if the ping doesn't arrive within the expected window, an alert fires.
This inverts the failure model. Instead of waiting for an exception, the monitoring service expects a ping. Silence is the alert condition. This catches every failure mode: broken crontab, crashed server, OOM kill, permission error, and exceptions thrown inside the command.
Add a ping at the end of your scheduled command using Laravel's thenPing() hook:
// In App\Console\Kernel.php
$schedule->command('reports:generate')
->daily()
->thenPing('https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN');
Or add the ping inside the command itself for more control:
// In App\Console\Commands\GenerateReports.php
public function handle(): int
{
// ... your command logic ...
// Ping heartbeat monitor on success
Http::get('https://cronwatch.example.com/api/heartbeat/YOUR_TOKEN');
return Command::SUCCESS;
}
The monitoring service then watches for incoming pings. If your daily command doesn't ping within 24 hours (plus a configurable grace period), you get an alert — email, webhook, or both.
Most Laravel apps accumulate scheduled tasks over time. Here are the commands worth monitoring with heartbeats:
| Command type | Why it matters if it stops | Suggested grace period |
|---|---|---|
| Database backups | Silent data loss if undetected for days | +2 hours |
| Billing / invoice generation | Revenue impact, customer trust issues | +1 hour |
| Email dispatch / queued notifications | Users don't receive time-sensitive emails | +30 minutes |
| Report generation | Stale dashboards; customers notice immediately | +1 hour |
| Cache warming | Performance degradation under load | +15 minutes |
| Cleanup / pruning jobs | Storage fills up, tables grow unbounded | +4 hours |
| Subscription status sync | Incorrect access grants or revocations | +1 hour |
For the top-level schedule:run entry — the one that runs every minute — you want to know immediately if the crontab breaks entirely. Add a dedicated heartbeat command:
// In Kernel.php — runs every 5 minutes as a scheduler health check
$schedule->call(function () {
Http::get('https://cronwatch.example.com/api/heartbeat/SCHEDULER_HEALTH_TOKEN');
})->everyFiveMinutes();
This pings the monitoring service every 5 minutes. If it goes quiet for more than 10 minutes (using a 5-minute grace period), your scheduler has stopped running — something you'll know about immediately rather than when a daily backup is discovered missing.
Laravel's Http facade (backed by Guzzle) is the cleanest way to send heartbeat pings. Since pings are fire-and-forget, suppress exceptions so a monitoring service being down doesn't break your command:
use Illuminate\Support\Facades\Http;
// Suppress exceptions — a heartbeat ping should never break the command
rescue(fn() => Http::timeout(5)->get($heartbeatUrl), report: false);
The rescue helper catches any exception and optionally reports it to your exception handler without re-throwing it. Heartbeat pings should never be a point of failure for the job itself.
If you use Laravel Forge, Ploi, or Envoyer to manage your server, these tools have their own cron management interfaces. The best practice is to manage your scheduler crontab entry through the tool rather than adding it manually — otherwise a sync will wipe it.
In Laravel Forge: Server → Scheduler → Add Scheduled Job
Command: php /var/www/your-app/artisan schedule:run
Frequency: Every minute
Forge also supports per-command scheduling from its UI, but the standard approach (one crontab entry + Kernel.php definitions) is more maintainable and easier to test locally.
Before adding heartbeat monitoring, verify your commands actually run as expected. Laravel provides a useful testing method:
// In a test
$this->artisan('schedule:run');
// Or to test a specific command in isolation
$this->artisan('reports:generate')->assertExitCode(0);
For end-to-end smoke-testing the scheduler in production, run it manually and watch the output:
php artisan schedule:run --verbose
The --verbose flag shows which commands are due and whether they ran successfully. This is useful for debugging why a command isn't running when expected.
CronWatch sends you an alert the moment a scheduled command misses its heartbeat ping — before your users notice. Add one line to your Laravel command and get email alerts automatically.
Get Early Access to CronWatch → Free Cron Expression Parserschedule:run stops entirelythenPing() for simple commands, or inline Http::get() for more controlrescue() — a heartbeat failure must not break the jobphp artisan schedule:run --verbose before adding monitoringThe hardest part of Laravel cron monitoring isn't the implementation — it's remembering to set it up. Most outages happen because heartbeat monitoring was "something we'd add later." Add it now, before the silent failure that causes a data incident.