Blog 2026-07-26
How to Monitor Laravel Scheduler with Heartbeats
Monitor Laravel's task scheduler with Seiri heartbeats — detect when scheduled commands stop running after deploys or server changes.
Laravel’s scheduler is elegant until the one crontab line that drives it disappears. Then every ->daily() command in routes/console.php becomes a no-op — with no exception, no failed job, and no Horizon alert.
Laravel scheduler monitoring is really two layers: the system cron that calls schedule:run, and each scheduled command’s business outcome. Both need an answer to Did it run?
Seiri is push / heartbeat monitoring, not uptime or APM. Your command pings when work finishes. Missed ping → alert. Same model as classic cron job monitoring.
The problem: a thin glue layer over cron
Laravel does not replace cron. Production setups almost always look like:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1Every minute, Artisan evaluates the schedule and runs due events. If that line is missing, wrong user, wrong path, or PHP fails before bootstrap, nothing in your Kernel runs. Application logs stay quiet. This is the same class of incident as cron job not running.
Even when schedule:run fires, individual commands can:
- Be removed from the schedule during a refactor
- Run under
withoutOverlapping()and stay locked forever after a crash - Exit
0after a skipped code path - Fail inside a subprocess that nobody monitors
Why Laravel schedules fail silently
1. System crontab gone after migrate / new host
AMI rebuilds, new containers, or “temporary” servers without config management drop the crontab. Laravel code is fine; the trigger is not.
2. Wrong user / wrong working directory
Cron runs as root with a different .env than deploy. schedule:run starts, then commands fail on DB credentials — or worse, write to the wrong disk and still exit successfully on soft checks.
3. withoutOverlapping mutex stuck
Laravel’s cache mutex prevents overlap. If a process dies hard, the lock can remain until TTL. Subsequent runs skip. Skipping is silent unless you log it.
4. onOneServer + cache driver issues
Multi-server scheduling needs a shared cache. Misconfigured Redis means every server thinks another ran the job — or none do.
5. Schedule definition drift
Moving from app/Console/Kernel.php to routes/console.php (Laravel 11+) mid-upgrade, or env-gated schedules (if (app()->isProduction())), can drop jobs in staging clones that later become production.
6. Success without proof
public function handle(): int
{
if (! $this->option('force') && ! $this->shouldRun()) {
return self::SUCCESS; // quiet skip
}
// ...
}Cron and the scheduler both see success. Downstream systems see stale data.
Common approaches
| Approach | Role | Limit |
|---|---|---|
| Server cron process check | Is crond up? | Does not prove schedule:run or commands |
Log schedule:run output | Debugging | Nobody pages on missing lines |
| Laravel Telescope / Horizon | Request / queue insight | Scheduler is often outside that path |
schedule:list manually | Inventory | Not continuous monitoring |
| Heartbeat per critical command | Missed completion | Requires a ping on success |
Heartbeats close the gap the same way you would for a raw shell backup: dead man’s switch on the success path.
Implementation
Seiri URLs
https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF
https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/success
https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/failCreate the monitor in cloud.seiri.app with a schedule matching the Artisan event (e.g. daily 02:00 + grace).
Option A: Ping inside the command
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class BackupDatabase extends Command
{
protected $signature = 'app:backup-database';
protected $description = 'Dump and upload the database';
public function handle(): int
{
try {
$this->runBackup(); // throw on failure
Http::timeout(10)->get(
'https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/success'
);
return self::SUCCESS;
} catch (\Throwable $e) {
report($e);
Http::timeout(10)->get(
'https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/fail'
);
return self::FAILURE;
}
}
}Register it:
// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:backup-database')
->dailyAt('02:00')
->withoutOverlapping(120)
->onOneServer();Option B: Laravel’s built-in ping helpers
Laravel can HTTP ping after events. Point them at Seiri:
Schedule::command('app:backup-database')
->dailyAt('02:00')
->pingOnSuccess('https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/success')
->pingOnFailure('https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/fail');Notes:
pingOnSuccessruns when the event reports success — if your command returnsSUCCESSafter a skip, you still ping. Prefer failing closed insidehandle()for critical jobs.- If the host never runs
schedule:run, neither ping fires — Seiri’s missed-check-in alert still saves you.
Option C: Monitor schedule:run itself (coarse)
A lightweight wrapper proves the crontab line is alive:
* * * * * cd /var/www/app && php artisan schedule:run \
&& curl -fsS -m 10 https://ping.seiri.app/webhook/YOUR_ORG:schedule-runUse a 5-minute Seiri monitor (cron runs every minute; alerting every minute is noisy — either ping every minute with a short grace, or ping on a dedicated Schedule::call every five minutes). This catches missing crontab; it does not prove app:backup-database completed. Keep per-command monitors for backups, invoices, and syncs.
Mutex and overlapping
Schedule::command('app:backup-database')
->dailyAt('02:00')
->withoutOverlapping(180) // minutes until lock expires
->onOneServer();If backups can run longer than the mutex TTL, overlapping or skips will confuse you. Set TTL above worst-case runtime, set active-style timeouts in the command, and still heartbeat completion so a permanent skip pages you.
Example with Seiri
- Inventory critical schedules:
php artisan schedule:list. - Create one Seiri monitor per high-value command (backup, invoices, partner sync).
- Add
pingOnSuccess/pingOnFailureor Http calls inhandle(). - Keep the system crontab in Ansible/cloud-init/Kubernetes CronJob — not hand-edited on one box.
- In staging, comment out the crontab line; confirm Seiri alerts after grace.
- If the app server has no egress HTTPS, use email heartbeats:
YOUR_REF@ping.seiri.app.
For Laravel on Kubernetes, schedule:run is often itself a CronJob — see Kubernetes CronJob monitoring, seiri-kube-agent, and kubernetes-agent.
schedule:run in containers and Platform-as-a-Service
Laravel on a single VM with a system crontab is the textbook setup. Many teams no longer have that:
- PHP-FPM / web containers with no cron binary —
schedule:runnever executes unless a separate process or CronJob exists. - Laravel Vapor / serverless — use the platform scheduler or an external trigger; still heartbeat inside the command.
- Kubernetes — run either a CronJob that executes
php artisan schedule:runevery minute, or one CronJob per critical Artisan command. Prefer the latter for backups you must not skip; pair with Kubernetes CronJob monitoring.
Example dedicated CronJob (skips the “every minute” indirection for one critical job):
apiVersion: batch/v1
kind: CronJob
metadata:
name: laravel-backup
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: artisan
image: myapp:production
command:
- php
- artisan
- app:backup-databaseThe command itself still pings Seiri after a verified dump/upload. If the CronJob is suspended, the ping stops.
Maintenance mode and environments()
Schedule::command('app:backup-database')
->dailyAt('02:00')
->environments(['production'])
->evenInMaintenanceMode(); // only if backups must run during deploy freezes
environments(['production']) on a host whose APP_ENV was left as staging after a clone is a frequent “scheduler not running” report. Heartbeats catch the symptom; fixing env is the cure. Maintenance mode without evenInMaintenanceMode() skips jobs for the whole freeze window — widen Seiri grace for planned freezes or pause the monitor deliberately.
Logging skips
When you intentionally skip, log it — and do not ping success:
public function handle(): int
{
if ($this->shouldSkip()) {
Log::warning('backup skipped', ['reason' => $this->skipReason()]);
return self::SUCCESS; // intentional — expect Seiri miss if skip lasts past grace
}
$this->runBackup();
Http::timeout(10)->get('https://ping.seiri.app/webhook/YOUR_ORG:YOUR_REF/success');
return self::SUCCESS;
}If skips are normal (e.g. weekends), encode that in the Seiri schedule or use a cron expression that matches — do not ping on skip days and hope.
Queue vs schedule
Schedule::job(new SyncPartners) pushes to the queue. The scheduler “succeeding” only means enqueue succeeded. Ping from the job’s handle() after the partner API confirms — same as Sidekiq/Celery. Otherwise a dead queue worker looks like a healthy schedule.
class SyncPartners implements ShouldQueue
{
public function handle(): void
{
Partner::active()->each->sync();
Http::timeout(10)->get('https://ping.seiri.app/webhook/YOUR_ORG:partners');
}
}Output and notification hooks (without mistaking them for monitoring)
Laravel can email on failure:
Schedule::command('app:backup-database')
->dailyAt('02:00')
->emailOutputOnFailure(config('mail.ops_address'));Useful for debugging. Not a substitute for Seiri: mail to ops can be filtered, delayed, or ignored, and it does not fire when schedule:run itself never ran. Keep emailOutputOnFailure if you want; add a heartbeat for the missed-run case.
appendOutputTo / sendOutputTo help postmortems. Pair them with push check-ins so you learn about silence before you open the log file.
Best practices
- Config-manage the crontab — treat “missing
schedule:run” as a deploy bug. - One Seiri monitor per critical command, plus optional coarse
schedule:runpulse. - Return
FAILUREwhen work did not happen — do notSUCCESSon unexpected skips. - Bound
withoutOverlappingTTLs to real runtimes; alert on missed heartbeats when skips occur. - Absolute paths and explicit
cdin cron; never rely on interactivePATH. - Test after every platform change — new image, new OS user, new secrets manager.
- Prefer HTTP ping when allowed; email when locked down.
- Ping from queued job bodies when
Schedule::jobonly enqueues. - Verify
APP_ENVand maintenance behavior wherever schedules are environment-gated.
Conclusion
Laravel’s scheduler only runs if system cron calls it, and “scheduled” only means “due” — not “succeeded at the business task.” Put Seiri heartbeats on the commands that matter so silence becomes an alert.
Start free at cloud.seiri.app. Related: heartbeat monitoring explained, cron job monitoring complete guide, cron job failed silently.
Transform Your Monitoring
Get started with Seiri
Heartbeat monitoring for cron jobs, workers, and Kubernetes — know when scheduled work goes silent.
Start free- Free forever
- No credit card required
- Cancel anytime
Would you know if this job stopped running?
Seiri watches your cron jobs, workers, and Kubernetes CronJobs, and alerts you the moment one goes silent. Free forever, no credit card.
Start free