How to monitor Laravel scheduled commands with heartbeat pings
Laravel's scheduler has built-in support for heartbeat pings — most developers just don't know about it. Here's how to use pingBefore, pingOnSuccess, and pingOnFailure to catch silent failures in your scheduled commands.
Laravel's task scheduler is one of those features that makes you forget about crontab almost entirely. You define your schedule in PHP, add one cron entry for schedule:run, and everything just works. Until it doesn't.
The problem isn't that Laravel's scheduler is unreliable. It's that when something fails — a command throws an exception, a queued job silently dies, or the scheduler itself stops running — there's no built-in mechanism to tell you about it. Laravel logs the error to storage/logs/laravel.log, and that's where the information stays unless you actively check.
I run several Laravel apps in production with dozens of scheduled commands between them. Backups, report generation, data sync, cleanup tasks. After losing data to a silent scheduler failure (I wrote about that experience in my post on what a dead man's switch is), I added heartbeat monitoring to every critical task. Here's how.
Laravel has this built in
Here's something a lot of Laravel developers don't know: the scheduler has native support for heartbeat pings. No package needed, no custom code. It's been there since Laravel 7.
The relevant methods are:
pingBefore($url)— sends a GET request before the task runsthenPing($url)— sends a GET request after the task completes (regardless of success or failure)pingOnSuccess($url)— pings only if the task exits with code 0pingOnFailure($url)— pings only if the task exits with a non-zero code
These methods work with any URL. Point them at a heartbeat monitoring service like WatchCron, and you get instant alerts when a scheduled task fails or doesn't run.
Basic setup: one line per task
The simplest version adds a single thenPing to your scheduled command. In Laravel 12, scheduled tasks live in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:backup-database')
->dailyAt('02:00')
->thenPing('https://watchcron.com/ping/your-uuid');
That's it. After app:backup-database runs, Laravel sends a GET request to the ping URL. If WatchCron doesn't receive the ping within the expected window, it sends you an alert.
But there's a catch with thenPing: it fires after the task finishes regardless of whether it succeeded or failed. If your command throws an exception and Laravel catches it, thenPing still fires. You'd get a ping even though the task didn't actually complete its work.
Better: use pingOnSuccess and pingOnFailure
For proper monitoring, you want to distinguish between success and failure. That's what pingOnSuccess and pingOnFailure are for:
use Illuminate\Support\Facades\Schedule;
$ping = 'https://watchcron.com/ping/your-uuid';
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingOnSuccess($ping)
->pingOnFailure("{$ping}/fail");
Now the success ping only fires if the command exits with code 0. If the command throws an exception or returns a non-zero exit code, only the failure URL gets pinged. WatchCron treats a /fail ping as an immediate alert — no waiting for the grace period to expire.
Full monitoring: start + success + fail
If you want the complete picture, including how long each task takes to run, add pingBefore for the start signal:
use Illuminate\Support\Facades\Schedule;
$ping = 'https://watchcron.com/ping/your-uuid';
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingBefore("{$ping}/start")
->pingOnSuccess($ping)
->pingOnFailure("{$ping}/fail");
With all three signals, WatchCron can track the actual runtime of each execution. And it can detect a new failure mode: a task that starts but never finishes. If the /start ping arrives but neither success nor failure comes within the grace period, something is stuck.
I use this three-signal pattern for all tasks that run longer than a few seconds — backups, report generation, data imports. For quick tasks like cache clearing or deleting expired tokens, pingOnSuccess alone is enough.
Monitoring multiple tasks
Each scheduled task should have its own monitor with a unique UUID. If you lump everything under one ping URL, you can't tell which task failed.
use Illuminate\Support\Facades\Schedule;
// Each task gets its own WatchCron check
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingBefore('https://watchcron.com/ping/uuid-backup/start')
->pingOnSuccess('https://watchcron.com/ping/uuid-backup')
->pingOnFailure('https://watchcron.com/ping/uuid-backup/fail');
Schedule::command('app:send-weekly-report')
->weeklyOn(1, '09:00')
->pingOnSuccess('https://watchcron.com/ping/uuid-report')
->pingOnFailure('https://watchcron.com/ping/uuid-report/fail');
Schedule::command('app:cleanup-temp-files')
->daily()
->pingOnSuccess('https://watchcron.com/ping/uuid-cleanup');
Schedule::command('app:sync-products')
->everyThirtyMinutes()
->pingOnSuccess('https://watchcron.com/ping/uuid-sync')
->pingOnFailure('https://watchcron.com/ping/uuid-sync/fail');
In my apps I typically have 5-15 scheduled tasks. The critical ones (backups, payment processing, data sync) get the full start/success/fail treatment. Less critical ones (cleanup, cache warming) get just pingOnSuccess.
Clean it up with environment variables
Hardcoded UUIDs in routes/console.php work, but they get messy fast. I prefer pulling them from .env:
# .env
WATCHCRON_BACKUP=https://watchcron.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000001
WATCHCRON_REPORT=https://watchcron.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000002
WATCHCRON_CLEANUP=https://watchcron.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000003
WATCHCRON_SYNC=https://watchcron.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000004
use Illuminate\Support\Facades\Schedule;
$backup = env('WATCHCRON_BACKUP');
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingBefore("{$backup}/start")
->pingOnSuccess($backup)
->pingOnFailure("{$backup}/fail");
This way, different environments (staging, production) can have different monitoring endpoints, and you don't leak UUIDs into version control.
Monitor the scheduler itself
Here's something most guides miss: if schedule:run isn't being called — because someone deleted the cron entry, or the server rebooted and cron didn't restart — none of your task-level monitoring will help. All your scheduled commands just silently stop running.
The fix is simple: add a dedicated heartbeat that proves the scheduler is alive:
use Illuminate\Support\Facades\Schedule;
// This runs every minute just to prove the scheduler is alive
Schedule::call(fn () => null)
->everyMinute()
->pingOnSuccess('https://watchcron.com/ping/uuid-scheduler-heartbeat');
This empty closure runs every minute and pings WatchCron on success. If the scheduler itself is down, this ping stops arriving, and you get an alert. I consider this the most important monitor in the whole setup — it's the one that catches the "everything is broken and nothing is reporting" scenario.
Set the expected schedule in WatchCron to "every 1 minute" with a grace period of 3-5 minutes. That gives enough room for minor delays without missing real outages.
Monitoring queued jobs dispatched from the scheduler
There's a subtle trap here. If your scheduled command dispatches a queued job, pingOnSuccess fires when the command finishes dispatching — not when the actual job completes. Your backup command might successfully push a job to the queue, but the job itself could fail later.
// This pings "success" when the job is DISPATCHED, not when it's DONE
Schedule::command('app:generate-reports')
->daily()
->pingOnSuccess($ping);
For queued work, move the heartbeat inside the job itself:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class GenerateReport implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// Your report logic here
$this->buildReport();
$this->sendToClients();
// Ping only after the actual work is done
Http::timeout(10)->get(
config('services.watchcron.report_url')
);
}
public function failed(\Throwable $exception): void
{
Http::timeout(10)->get(
config('services.watchcron.report_url') . '/fail'
);
}
}
The failed method is called by Laravel's queue system when the job fails after all retry attempts. This way, the heartbeat tracks the actual work, not just the dispatch.
Using before/after hooks for more control
If you need to send custom data with the heartbeat (like number of records processed or execution time), use the before and after callbacks instead of the built-in ping methods:
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:sync-products')
->hourly()
->before(function () {
Http::timeout(10)->get('https://watchcron.com/ping/your-uuid/start');
})
->after(function () {
Http::timeout(10)->post('https://watchcron.com/ping/your-uuid', [
'body' => 'Synced ' . Product::count() . ' products',
]);
})
->onFailure(function () {
Http::timeout(10)->get('https://watchcron.com/ping/your-uuid/fail');
});
The body content shows up in WatchCron's ping log, which helps with debugging. When I look at the dashboard and see "Synced 0 products" instead of the usual 1,400, I know something is wrong even though the task technically succeeded.
Conditional pinging
Laravel also provides conditional versions of all ping methods: pingBeforeIf, thenPingIf, pingOnSuccessIf, and pingOnFailureIf. These are useful if you only want to monitor in certain environments:
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingOnSuccessIf(app()->isProduction(), $ping)
->pingOnFailureIf(app()->isProduction(), "{$ping}/fail");
No point getting heartbeat alerts from your local dev machine. I wrap all my production pings with app()->isProduction() to avoid noise during development.
Don't forget the cron entry
All of this depends on one thing: the scheduler actually running. Make sure this cron entry exists on your server:
* * * * * cd /var/www/your-app && php artisan schedule:run >> /dev/null 2>&1
If you're using Laravel Forge, Ploi, or any managed deployment platform, this is usually set up automatically. But it's worth double-checking — especially after server migrations or OS upgrades.
During development, use php artisan schedule:work to run the scheduler in the foreground. And php artisan schedule:list shows all registered tasks with their next scheduled run time — handy for verifying everything is wired up correctly.
What I monitor in my own Laravel apps
Here's my actual setup across the apps I run. Not theoretical, this is what's running right now:
Scheduler heartbeat — empty closure, every minute. The most important one.
Database backup — daily at 2 AM, full start/success/fail signals, 30-minute grace period.
Paddle webhook retry — every 5 minutes, success only. If it stops, payments aren't being processed.
Expired session cleanup — daily, success only. Low priority, but disk fills up if it breaks.
Screenshot cache cleanup — hourly, success only. Object storage costs go up if old cache isn't pruned.
Total: about 8-10 monitors per app. WatchCron's free tier covers 5 checks, which is enough for a single app's critical tasks. For multiple apps, the Starter plan at $9/month covers 25 checks.
Quick reference
Here's a cheat sheet of the ping methods available in Laravel 12's scheduler:
| Method | When it fires | WatchCron endpoint |
|---|---|---|
pingBefore($url) | Before task starts | /ping/uuid/start |
thenPing($url) | After task finishes (always) | /ping/uuid |
pingOnSuccess($url) | After task succeeds (exit 0) | /ping/uuid |
pingOnFailure($url) | After task fails (non-zero exit) | /ping/uuid/fail |
My recommended combo for critical tasks: pingBefore → /start, pingOnSuccess → /ping, pingOnFailure → /fail.
For more details on how heartbeat monitoring works and why your cron jobs need it, check out my article on dead man's switch monitoring. And if you want the raw curl approach without Laravel's built-in methods, I cover that in monitoring cron jobs with curl.
If you have a different monitoring setup in your Laravel apps, I'm curious to hear about it. I'm always looking for patterns I haven't thought of.
Vitalii Holben