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.

7 min read Seiri Team Laravel scheduler monitoringcron monitoringheartbeat monitoringPHP

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>&1

Every 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 0 after 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

ApproachRoleLimit
Server cron process checkIs crond up?Does not prove schedule:run or commands
Log schedule:run outputDebuggingNobody pages on missing lines
Laravel Telescope / HorizonRequest / queue insightScheduler is often outside that path
schedule:list manuallyInventoryNot continuous monitoring
Heartbeat per critical commandMissed completionRequires 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/fail

Create 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:

  • pingOnSuccess runs when the event reports success — if your command returns SUCCESS after a skip, you still ping. Prefer failing closed inside handle() 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-run

Use 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

  1. Inventory critical schedules: php artisan schedule:list.
  2. Create one Seiri monitor per high-value command (backup, invoices, partner sync).
  3. Add pingOnSuccess / pingOnFailure or Http calls in handle().
  4. Keep the system crontab in Ansible/cloud-init/Kubernetes CronJob — not hand-edited on one box.
  5. In staging, comment out the crontab line; confirm Seiri alerts after grace.
  6. 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:run never 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:run every 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-database

The 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

  1. Config-manage the crontab — treat “missing schedule:run” as a deploy bug.
  2. One Seiri monitor per critical command, plus optional coarse schedule:run pulse.
  3. Return FAILURE when work did not happen — do not SUCCESS on unexpected skips.
  4. Bound withoutOverlapping TTLs to real runtimes; alert on missed heartbeats when skips occur.
  5. Absolute paths and explicit cd in cron; never rely on interactive PATH.
  6. Test after every platform change — new image, new OS user, new secrets manager.
  7. Prefer HTTP ping when allowed; email when locked down.
  8. Ping from queued job bodies when Schedule::job only enqueues.
  9. Verify APP_ENV and 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
Share X / Twitter LinkedIn