Healthchecks.io Is the Dead-Man Switch Your Laravel Scheduler Needs
Your cron jobs are silently failing and you don't know it. Here's how I wire Healthchecks.io into Laravel Scheduler so I actually find out.
The scariest class of production bug is the one where nothing throws an exception. The job runs, exits zero, and Laravel is perfectly happy — but the work didn't actually happen. I've had scheduled imports stop pulling data because an upstream API quietly changed an endpoint. I've had cleanup jobs that stopped deleting rows because a soft-delete scope snuck into a query. Crickets. No alerts. Just stale data accumulating until a client calls.
Healthchecks.io is a dead-man switch. You tell it "this job should check in every hour." If it doesn't hear from the job, it pages you. That's the whole model, and it's exactly right for cron monitoring.
What Problem This Actually Solves
Laravel Scheduler is great for defining when jobs run. It's terrible at telling you whether they ran successfully. The built-in emailOutputTo and onFailure hooks only fire on thrown exceptions. Silent failures — wrong row count, network timeout that gets swallowed, a return early buried in business logic — those just disappear.
Deadman-switch monitoring flips the accountability model. Instead of "alert me when something goes wrong," it's "alert me if I don't hear from you." That's a fundamentally more reliable guarantee. The job has to actively report success. Silence is treated as failure.
Healthchecks.io gives you a URL per job. You ping it when the job completes successfully. If the ping doesn't arrive within the configured grace period, you get notified via email, Slack, PagerDuty, whatever you've wired up. The free tier covers 20 checks, which is enough for most small applications. The hosted service costs $20/month for 100 checks. You can also self-host the open-source version if you're running something regulated and don't want your monitoring data leaving your infrastructure — I've done this for a couple healthcare clients.
The Basic Setup
First, install Guzzle if it's not already in your project (it usually is in a Laravel app):
composer require guzzlehttp/guzzle
Create a check on healthchecks.io, set the schedule and grace period, and grab the ping URL. It looks like https://hc-ping.com/your-uuid-here. Stick it in .env:
HC_PING_IMPORT_ORDERS=https://hc-ping.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Now in app/Console/Kernel.php, the naive approach is to just curl the URL after the job runs:
$schedule->job(new ImportOrders)
->hourly()
->thenPing(env('HC_PING_IMPORT_ORDERS'));
That works, and it's the easiest path. Laravel's thenPing() fires after the job completes — success or failure. Which is already a problem I'll get to in a second.
A More Honest Implementation
The thenPing() convenience method doesn't distinguish between success and failure. If ImportOrders throws an exception that Laravel catches and logs, thenPing() still fires. Your dead-man switch just got neutered.
Healthchecks.io supports a three-ping protocol: /start, a success ping (just the base URL), and /fail. Use all three:
$schedule->job(new ImportOrders)
->hourly()
->pingBefore(env('HC_PING_IMPORT_ORDERS') . '/start')
->onSuccess(function () {
Http::get(env('HC_PING_IMPORT_ORDERS'));
})
->onFailure(function () {
Http::get(env('HC_PING_IMPORT_ORDERS') . '/fail');
});
The /start ping tells Healthchecks.io the job began. This lets it detect jobs that start but never finish — hung processes, infinite loops, whatever. The success ping is what resets the dead-man timer. The /fail ping immediately triggers an alert rather than waiting for the grace period to expire.
I wrap this in a small helper so I'm not copy-pasting the pattern across twenty jobs:
<?php
namespace App\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Healthcheck
{
public static function monitor(string $envKey): array
{
$url = config('healthchecks.' . $envKey);
if (! $url) {
Log::warning("Healthcheck URL not configured for: {$envKey}");
return [fn() => null, fn() => null, fn() => null];
}
$ping = function (string $suffix = '') use ($url) {
try {
Http::timeout(5)->get($url . $suffix);
} catch (\Throwable $e) {
Log::error("Healthcheck ping failed [{$envKey}]: " . $e->getMessage());
}
};
return [
fn() => $ping('/start'), // before
fn() => $ping(''), // success
fn() => $ping('/fail'), // failure
];
}
}
I keep a config/healthchecks.php that maps keys to URLs:
<?php
return [
'import_orders' => env('HC_PING_IMPORT_ORDERS'),
'sync_inventory' => env('HC_PING_SYNC_INVENTORY'),
'prune_sessions' => env('HC_PING_PRUNE_SESSIONS'),
'send_reminders' => env('HC_PING_SEND_REMINDERS'),
];
And then in Kernel.php it reads cleanly:
use App\Support\Healthcheck;
// In schedule():
[$start, $success, $fail] = Healthcheck::monitor('import_orders');
$schedule->job(new ImportOrders)
->hourly()
->pingBefore($start) // Hmm — see gotcha below
->onSuccess($success)
->onFailure($fail);
Except pingBefore() expects a URL string, not a closure. onSuccess() and onFailure() accept closures. So pingBefore needs to become a before() callback:
$schedule->job(new ImportOrders)
->hourly()
->before($start)
->onSuccess($success)
->onFailure($fail);
That's the working version.
The Gotchas That Will Bite You
Grace period math is tricky. If a job runs every 5 minutes, don't set a 1-minute grace period thinking that's "tight" monitoring. Network latency, scheduler jitter, and a slow job can all push the ping past a 1-minute window and generate false alerts. I typically set grace to 50% of the interval for short-cycle jobs, and a flat 15-30 minutes for anything hourly or longer.
The scheduler itself can fail to run. Healthchecks.io monitors job pings, not the cron daemon. If the single cron entry that fires php artisan schedule:run dies — bad deploy, OOM kill, crontab corruption — nothing gets pinged and you'll eventually get alerted. Good. But make sure you also have a separate check just for schedule:run itself. I run a tiny shell wrapper:
# crontab
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1 && curl -s https://hc-ping.com/scheduler-uuid > /dev/null
That pings once per minute when the scheduler process exits successfully. If the scheduler stops running for any reason, I hear about it within minutes plus grace period.
onFailure() doesn't fire on queue job failures. This one burned me. When you do $schedule->job(new ImportOrders), Laravel dispatches the job onto the queue. The scheduled task itself succeeds — it just pushed a job. Whether that queued job actually ran and succeeded is a completely separate concern. If you need to monitor queued job outcomes, the ping logic has to live inside the job's handle() and failed() methods, not in Kernel.php.
Multiple environments hitting the same check. Staging shares .env values with production more often than you'd think, especially when a junior dev copies a .env.example. If staging is pinging the same Healthchecks.io UUID as production, your timers are getting reset by the wrong environment. Always create separate checks per environment, or conditionally suppress pings:
$ping = function (string $suffix = '') use ($url) {
if (! app()->isProduction()) {
return;
}
// ...
};
The /start ping and the "run duration" alert. When you use /start, Healthchecks.io can alert you if the job runs longer than expected. This is great — it catches hung jobs. But you need to explicitly set the expected duration in the check configuration, otherwise you'll get no benefit from sending /start at all. Worth the 30 seconds to configure.
When I'd Reach for This
Every production application I run at NWOS now has Healthchecks.io wired up for anything scheduled. Doesn't matter if it's a simple database prune or a complex multi-step sync. The cost is negligible and the peace of mind is real.
I'd especially reach for it when:
- The job touches data clients depend on (inventory sync, billing, report generation)
- The job has no obvious side effect that a user would notice right away
- You're integrating a third-party API that occasionally goes dark without warning
- You want a run-duration baseline to catch performance regressions over time
When I wouldn't bother: jobs that already produce highly visible output (send an email to a real user, generate a file the client downloads immediately). Those have their own natural alerting loop. Also, one-off artisan commands you're running manually — those aren't scheduled, so there's nothing to monitor.
For self-hosted deployments where I can't use the hosted service, I've run the open-source healthchecks Django app on a small VPS. It's straightforward to stand up and the feature set is identical. Two things to watch: keep it on a different host than your application, and make sure the monitoring server itself has uptime monitoring (yes, you need to monitor your monitor).
Closing
Silent cron failure is a slow-moving disaster. I've seen it erode client trust more than loud exceptions ever have — because by the time anyone notices, the data is already wrong, and it's been wrong for a while. Healthchecks.io costs almost nothing, takes an afternoon to wire up properly, and turns "I hope the scheduler is running" into "I'll know within minutes if it's not." That's not over-engineering — that's just how production software should work.
Need help shipping something like this? Get in touch.