log in
consulting hosting industries the daily tools about contact

Dead-Man Monitoring for Laravel Cron Jobs Without Paying for It

Paid cron monitoring SaaS is fine until you're running 40 scheduled tasks across a dozen client apps. Here's how I do heartbeat monitoring myself.

Paid cron monitoring tools are great until you're managing scheduled tasks across a dozen client applications and the per-monitor pricing starts adding up faster than the problems it's catching. I've used Cronitor, I've used Healthchecks.io, and they're genuinely well-built products. But at some point I got tired of the dependency, the monthly invoice, and the occasional moment where the monitoring service itself had an outage. So I built the thing myself. It's not complicated, and I think more shops should do it.

Here's the actual implementation I've been running in production.

What Heartbeat Monitoring Actually Is

The classic "dead-man switch" model is simple: your job pings an endpoint every time it completes successfully. A separate watcher checks periodically whether any job has gone silent — missed its expected window — and fires an alert. You're not monitoring whether the job ran, you're monitoring whether it checked in. Subtle difference, but it means the alert fires whether the job crashed, the scheduler daemon died, the server went down, or a deploy broke something.

This is different from just logging job execution. Logs tell you what happened. A dead-man check alerts you when nothing happened — which is the failure mode that hurts most in production.

The Database Schema

Two tables. One stores the heartbeat registrations (what jobs I expect and how often), the other stores the actual pings.

// In your migration
Schema::create('cron_monitors', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();
    $table->string('label');
    $table->unsignedInteger('interval_minutes');
    $table->unsignedInteger('grace_minutes')->default(5);
    $table->timestamp('last_ping_at')->nullable();
    $table->boolean('alerted')->default(false);
    $table->timestamps();
});

interval_minutes is how often the job should run. grace_minutes is the buffer before I consider it late. alerted tracks whether I've already fired a notification so I don't spam Slack every check cycle.

I don't keep a separate pings table — I just update last_ping_at in place. For most of what I'm running, I don't need a full ping history. If you want trend analysis or a dashboard, add a cron_pings table and log each hit. For basic alerting, this is enough.

The Ping Endpoint

Each job hits a simple internal route when it finishes successfully. I keep this internal — not exposed publicly — by either checking a secret token in the request or running it as a direct model call from the job itself rather than an HTTP request.

Direct model call is cleaner for same-server jobs:

// app/Support/CronMonitor.php
namespace App\Support;

use App\Models\CronMonitor;
use Illuminate\Support\Carbon;

class CronMonitorPing
{
    public static function ping(string $slug): void
    {
        CronMonitor::where('slug', $slug)->update([
            'last_ping_at' => Carbon::now(),
            'alerted'      => false,
        ]);
    }
}

Then at the end of any scheduled job:

// Inside your scheduled Artisan command's handle()
public function handle(): int
{
    // ... actual job logic ...

    CronMonitorPing::ping('daily-invoice-export');

    return Command::SUCCESS;
}

The alerted reset is important. If a job was late, I got alerted, then it recovered — I want to know when it fails again, not stay in a silenced state.

Seeding Your Monitors

I seed the monitors table with every job I care about at deploy time. In DatabaseSeeder or a dedicated seeder:

$monitors = [
    ['slug' => 'daily-invoice-export',   'label' => 'Daily Invoice Export',   'interval_minutes' => 1440, 'grace_minutes' => 30],
    ['slug' => 'hourly-feed-sync',       'label' => 'Hourly Feed Sync',       'interval_minutes' => 60,   'grace_minutes' => 10],
    ['slug' => 'fifteen-min-queue-trim', 'label' => '15-min Queue Trim',      'interval_minutes' => 15,   'grace_minutes' => 5],
];

foreach ($monitors as $m) {
    CronMonitor::updateOrCreate(['slug' => $m['slug']], $m);
}

This means new jobs get registered automatically on the next deploy and I don't have to manually configure anything in an external dashboard.

The Watcher Job

This is the piece that actually sends alerts. It's a scheduled Artisan command that runs every five minutes and looks for monitors that have gone silent past their grace window.

// app/Console/Commands/CheckCronMonitors.php
namespace App\Console\Commands;

use App\Models\CronMonitor;
use App\Notifications\CronMonitorLateNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;

class CheckCronMonitors extends Command
{
    protected $signature   = 'monitors:check';
    protected $description = 'Check for late cron jobs and fire alerts';

    public function handle(): int
    {
        CronMonitor::query()
            ->where('alerted', false)
            ->whereNotNull('last_ping_at')
            ->get()
            ->each(function (CronMonitor $monitor) {
                $deadline = $monitor->last_ping_at
                    ->addMinutes($monitor->interval_minutes)
                    ->addMinutes($monitor->grace_minutes);

                if (Carbon::now()->greaterThan($deadline)) {
                    Notification::route('slack', config('monitoring.slack_webhook'))
                        ->notify(new CronMonitorLateNotification($monitor));

                    $monitor->update(['alerted' => true]);
                }
            });

        return Command::SUCCESS;
    }
}

And in routes/console.php (or app/Console/Kernel.php if you're pre-Laravel 11):

Schedule::command('monitors:check')->everyFiveMinutes();

The Slack notification is a standard Laravel Notification class pointing at an incoming webhook. Nothing fancy — I just include the job label, when it last checked in, and how overdue it is.

The Gotchas That Will Bite You

The scheduler itself can die. This is the meta-problem. If * * * * * php artisan schedule:run drops out of crontab after a server migration, or the PHP binary path changes, nothing runs — including monitors:check. Your monitoring is now as dead as the jobs it was supposed to watch.

The fix: run a single external ping from the app back to a free tier of Healthchecks.io or a simple webhook you own, just to confirm the scheduler is alive. One external check for the scheduler itself is worth it. You're not paying per-monitor, you're paying for a single heartbeat that the whole scheduler is running.

Null last_ping_at on new deploys. When a monitor is first seeded, last_ping_at is null. My watcher query skips nulls intentionally — I don't want alerts firing for jobs that haven't had a chance to run yet. But this means if a brand-new job never runs at all, it won't alert either. I handle this by initializing last_ping_at to now() in the seeder, accepting that there's a brief cold-start window.

Grace periods need to be honest. I had an hourly sync job that sometimes took 25 minutes to run, so the next invocation would start late, which meant its completion ping was late, which triggered false alerts. Set your grace period based on actual job duration, not what you wish the job took. Query your logs for p95 runtime before you set these numbers.

Midnight UTC vs. server time. If a job is scheduled daily() in Laravel and your server is in UTC but your brain thinks in Pacific time, your monitor's 1440-minute interval is correct but you'll confuse yourself reading timestamps. Store everything in UTC, display in local time. Standard advice, still bites people.

Jobs that legitimately stop running. If I decommission a scheduled task but leave its monitor row in the database, I'll start getting false alerts. Part of my deploy checklist is cleaning up orphaned monitor slugs. A soft-delete active boolean on the monitor row handles this cleanly.

When I'd Reach for This

This setup is exactly right for a shop running multiple client applications on their own servers where you want consistent monitoring without a per-app SaaS subscription. If you're running one app with five cron jobs and budget isn't a concern, just pay for Cronitor. The UX is better, the historical data is there, and it's not worth the build time.

But if you're in my position — managing apps across healthcare clients where you can't send job metadata to a third-party SaaS for compliance reasons, or you just want everything in your own infrastructure — this rolls up in about an afternoon and you own it completely.

I'd also reach for this for any client that needs an audit trail of scheduled task completion and doesn't want that data living in someone else's database. Healthcare and biotech clients in particular sometimes have opinions about what systems their operational data touches.

I wouldn't use this as a replacement for proper queue monitoring. Failed queue jobs are a different failure mode — Laravel Horizon or a Dead Letter Queue setup handles those. This is specifically for scheduled tasks that fire on a clock.

One More Thing

Add a simple admin route that renders a table of all monitors, their last ping time, and whether they're currently healthy. Even a quick Blade table. When a client asks "did the overnight export run?" you want to answer that question in five seconds, not go dig through logs.

// A controller method, nothing fancy
public function index()
{
    $monitors = CronMonitor::orderBy('label')->get()->map(function ($m) {
        $deadline = $m->last_ping_at
            ?->addMinutes($m->interval_minutes)
            ->addMinutes($m->grace_minutes);

        return [
            'label'    => $m->label,
            'last_ping' => $m->last_ping_at?->diffForHumans() ?? 'Never',
            'status'   => $m->alerted ? 'late' : ($deadline && now()->gt($deadline) ? 'late' : 'ok'),
        ];
    });

    return view('admin.monitors', compact('monitors'));
}

Green/red status column, last ping timestamp, done. It's not a SaaS dashboard but it answers the question.

Cron monitoring is one of those things that's easy to skip because it's not a feature — it's infrastructure. But silent job failures are some of the worst production bugs because you find out from a client, not from an alert. Build the thing. It's an afternoon of work and it'll pay for itself the first time a job quietly dies at 2am and you wake up knowing about it.

Need help shipping something like this? Get in touch.