WatchCron

How to monitor Laravel scheduled commands with heartbeat pings

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.

We 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 (we wrote about that experience in our post on what a dead man's switch is), we added heartbeat monitoring to every critical task. Here's how.

Laravel's scheduler has built-in heartbeat pings

Here's something most Laravel developers miss: 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 runs

  • thenPing($url) – sends a GET request after the task completes (regardless of success or failure)

  • pingOnSuccess($url) – pings only if the task exits with code 0

  • pingOnFailure($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, 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://monitoring.example.com/ping/your-uuid');

That's it. After app:backup-database runs, Laravel sends a GET request to the ping URL. If the monitoring service 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.

Separate success from failure with 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://monitoring.example.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. Most monitoring tools treat 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://monitoring.example.com/ping/your-uuid';

Schedule::command('app:backup-database')
    ->dailyAt('02:00')
    ->pingBefore("{$ping}/start")
    ->pingOnSuccess($ping)
    ->pingOnFailure("{$ping}/fail");

With all three signals, the monitoring service 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.

We 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 monitoring check
Schedule::command('app:backup-database')
    ->dailyAt('02:00')
    ->pingBefore('https://monitoring.example.com/ping/uuid-backup/start')
    ->pingOnSuccess('https://monitoring.example.com/ping/uuid-backup')
    ->pingOnFailure('https://monitoring.example.com/ping/uuid-backup/fail');

Schedule::command('app:send-weekly-report')
    ->weeklyOn(1, '09:00')
    ->pingOnSuccess('https://monitoring.example.com/ping/uuid-report')
    ->pingOnFailure('https://monitoring.example.com/ping/uuid-report/fail');

Schedule::command('app:cleanup-temp-files')
    ->daily()
    ->pingOnSuccess('https://monitoring.example.com/ping/uuid-cleanup');

Schedule::command('app:sync-products')
    ->everyThirtyMinutes()
    ->pingOnSuccess('https://monitoring.example.com/ping/uuid-sync')
    ->pingOnFailure('https://monitoring.example.com/ping/uuid-sync/fail');

In our apps we 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. We prefer pulling them from .env:

# .env
HEARTBEAT_BACKUP=https://monitoring.example.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000001
HEARTBEAT_REPORT=https://monitoring.example.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000002
HEARTBEAT_CLEANUP=https://monitoring.example.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000003
HEARTBEAT_SYNC=https://monitoring.example.com/ping/a1b2c3d4-e5f6-7890-abcd-000000000004
use Illuminate\Support\Facades\Schedule;

$backup = env('HEARTBEAT_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, not just the tasks

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://monitoring.example.com/ping/uuid-scheduler-heartbeat');

This empty closure runs every minute and pings the monitoring service on success. If the scheduler itself is down, this ping stops arriving, and you get an alert. We 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 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 custom data

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://monitoring.example.com/ping/your-uuid/start');
    })
    ->after(function () {
        Http::timeout(10)->post('https://monitoring.example.com/ping/your-uuid', [
            'body' => 'Synced ' . Product::count() . ' products',
        ]);
    })
    ->onFailure(function () {
        Http::timeout(10)->get('https://monitoring.example.com/ping/your-uuid/fail');
    });

The body content shows up in the monitoring service's ping log, which helps with debugging. When we look at the dashboard and see "Synced 0 products" instead of the usual 1,400, we know something is wrong even though the task technically succeeded.

Conditional pinging for production only

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. We wrap all our 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 we monitor in our own Laravel apps

Here's our actual setup across the apps we 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 20 checks, which is enough for a single app's critical tasks. For multiple apps, the Starter plan at $7/month covers 75 checks.

Laravel 12 scheduler ping methods at a glance

Here's a cheat sheet of the ping methods available in Laravel 12's scheduler:

MethodWhen it firesPing 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

Our 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 our article on dead man's switch monitoring. And if you want the raw webhook-based approach without Laravel's built-in methods, we cover that in monitoring cron jobs with curl.

If you've found a pattern that handles chained queued jobs better, or a way to monitor Horizon's supervisor itself, we'd like to hear about it.