Blog

How to monitor the Laravel scheduler: why it stops silently, heartbeat pings with pingOnSuccess and queue workers

The Laravel scheduler fails without errors when cron, locks or deploys break. Add heartbeat pings per task and a queue round-trip check so silence triggers an alert.

To monitor the Laravel scheduler, give it a heartbeat. Every important scheduled task pings a unique URL with pingOnSuccess or thenPing, and an external monitor alerts you when an expected ping does not arrive. Add one trivial every-minute task that only pings, so you also notice when the cron entry or schedule:run itself is gone, and give queue workers their own check, because a task that dispatches into a dead queue still looks successful.

The general idea of push-based checks is covered in our guide to heartbeat monitoring for cron jobs. This article is about the Laravel-specific ways the scheduler breaks and the exact code to catch each one.

Why the Laravel scheduler stops silently

The scheduler is a single crontab line that runs php artisan schedule:run every minute. Everything else depends on that line, on the PHP process booting cleanly and on Laravel deciding the task is due. A scheduler that is not running produces no errors, no logs and no failed jobs, so the only reliable signal is the absence of an expected ping.

The cron entry did not survive a server move

The cron entry lives outside your repository. When you move to a new server, rebuild an image or switch to containers, it is the step people forget. Common variants of the same problem:

  • The crontab was installed for root on the old server and for nobody on the new one, or for the wrong user, so file permissions break on the first write.
  • Cron runs with a minimal PATH, so php resolves to nothing or to a different PHP version than your web server uses.
  • The path points to a specific release directory that a deploy tool later deleted, instead of the current symlink.
  • In a container setup, the app container has no cron daemon at all and no separate scheduler container was added.

A stale withoutOverlapping lock

withoutOverlapping stores a lock in the cache while a task runs. If the process is killed mid-run (OOM killer, server reboot, deploy that kills PHP processes), the lock is never released. By default it expires after 24 hours, so a task that should run every five minutes is skipped silently for a full day. Pass an explicit expiry in minutes that matches the task, such as withoutOverlapping(30), and clear stuck locks with php artisan schedule:clear-cache.

Failed or half-finished deploys

Laravel skips scheduled tasks while the application is in maintenance mode, unless a task uses evenInMaintenanceMode(). A deploy script that runs php artisan down and then fails before php artisan up leaves the site possibly reachable through a cached page or a load balancer, but the scheduler does nothing. Other deploy failures break the boot itself: a failed composer install, a missing extension or an exception in a service provider makes every schedule:run invocation crash before any task is evaluated.

Errors that never leave the server

The standard crontab line ends in >> /dev/null 2>&1. That is fine for normal operation, but it means a fatal error at boot goes nowhere. Even with logging, nobody reads a log file for an event that did not happen.

Check whether the scheduler is running right now

Before adding monitoring, confirm the current state on the server:

# Which user has the crontab, and does it point to the right path?
crontab -l -u www-data

# Did cron actually execute it? (Debian/Ubuntu)
grep CRON /var/log/syslog | grep schedule:run | tail -5

# What does Laravel think is scheduled, and when is it due next?
php artisan schedule:list

# Run it once manually as the cron user and read the output
sudo -u www-data php artisan schedule:run

A robust crontab line uses absolute paths and the deploy symlink, and keeps output somewhere you can read while debugging:

* * * * * cd /var/www/app/current && /usr/bin/php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1

If you log to a file, rotate it. The point is not to read it daily but to have it when an alert fires.

How to monitor the Laravel scheduler with heartbeat pings

Use two layers: one heartbeat that proves the scheduler itself runs, and one heartbeat per task that matters to customers or revenue.

  1. Create the heartbeat URLs in your monitoring tool: one for the scheduler, one per critical task.
  2. Put the URLs in config, not in env() calls in your schedule. With php artisan config:cache, env() returns null outside config files. Add them to config/services.php:
    'heartbeats' => [
        'scheduler' => env('HEARTBEAT_SCHEDULER_URL'),
        'invoices'  => env('HEARTBEAT_INVOICES_URL'),
        'queue_default' => env('HEARTBEAT_QUEUE_DEFAULT_URL'),
    ],
  3. Add the scheduler heartbeat. In Laravel 11 and later this goes into routes/console.php; in older versions into the schedule() method of app/Console/Kernel.php using $schedule-> instead of the facade:
    use Illuminate\Support\Facades\Schedule;
    
    Schedule::call(fn () => null)
        ->name('scheduler-heartbeat')
        ->everyMinute()
        ->evenInMaintenanceMode()
        ->thenPing(config('services.heartbeats.scheduler'));

    Whether to use evenInMaintenanceMode() here is a choice. Leave it out if you want a stuck maintenance mode to trigger an alert, which is usually the more useful behaviour.

  4. Add pings to critical tasks:
    Schedule::command('invoices:generate')
        ->dailyAt('02:00')
        ->withoutOverlapping(120)
        ->onOneServer()
        ->pingOnSuccess(config('services.heartbeats.invoices'));
  5. Make failures return a non-zero exit code. pingOnSuccess fires when the command exits with 0. A command that catches every exception and returns Command::SUCCESS will ping even when it did nothing useful. Return Command::FAILURE when the work did not complete.
  6. Test the failure path. Comment out the crontab line on staging and confirm the alert arrives within the grace period you configured.

The ping methods use the Guzzle HTTP client, which is included in new Laravel projects by default. If you removed it, install guzzlehttp/guzzle again.

pingOnSuccess, thenPing or pingBefore: which ping method to use

Laravel offers several hooks, and they prove different things:

  • thenPing($url) fires after the task finishes, regardless of the result. It proves the task ran, not that it worked. Use it for the scheduler heartbeat.
  • pingOnSuccess($url) fires only on exit code 0. Use it for business tasks where a failed run should count as a missing run.
  • pingOnFailure($url) fires only on failure. Point it at a separate alert webhook or a failure endpoint, if your heartbeat service has one, to get an immediate alert instead of waiting for the grace period to run out.
  • pingBefore($url) fires when the task starts. Useful if your service can measure duration from a start and an end signal.
  • pingBeforeIf() and thenPingIf() take a condition first, for example to ping only in production.

A practical default: thenPing for the scheduler heartbeat, pingOnSuccess plus pingOnFailure for tasks where a failure needs attention before the next scheduled run.

Choosing expected intervals and grace periods

A heartbeat monitor needs an expected interval and a grace period: how late a ping may be before you get alerted. Too tight, and every deploy or slow run pages someone. Too loose, and you find out after customers do.

  • Scheduler heartbeat (every minute): expected interval 1 minute, grace 3 to 5 minutes. A single missed minute during a deploy should not alert.
  • Frequent tasks (every 5 to 15 minutes): interval equal to the schedule, grace of one or two intervals.
  • Daily or nightly tasks: interval 24 hours, grace equal to the longest normal runtime plus a buffer. A job that usually takes 10 minutes and sometimes 40 gets a grace of about an hour.

Remember that pingOnSuccess fires at the end of a run, so the ping arrives at start time plus runtime. Size the grace period from the latest normal finish time, not the start time. If a task pages too often for reasons nobody acts on, revisit it with the rules from our article on cutting alert noise.

Monitoring queue workers too

Many scheduled tasks only dispatch jobs. Schedule::job(...) succeeds as soon as the job is pushed onto the queue, so the task heartbeat stays green while the jobs pile up behind a dead worker. Supervisor restarting a crashing worker in a loop looks the same from the outside.

The most reliable check is a round trip: let the scheduler dispatch a tiny job, and let the job itself send the ping. The ping only arrives if cron, the scheduler, the queue connection and a worker on that queue all work.

// app/Jobs/QueueHeartbeat.php
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

class QueueHeartbeat implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $url) {}

    public function handle(): void
    {
        Http::timeout(5)->get($this->url);
    }
}

// routes/console.php
Schedule::job(
    new QueueHeartbeat(config('services.heartbeats.queue_default')),
    'default'
)->everyFiveMinutes();

Add one heartbeat per queue that has its own workers, such as default, emails or exports. In versions before Laravel 11, use the Dispatchable, InteractsWithQueue, Queueable and SerializesModels traits instead of the single Queueable trait.

A side effect is useful: if the queue has a large backlog, the heartbeat job waits in line and the ping arrives late. The grace period therefore doubles as your acceptable queue latency. With a 5 minute schedule and a 10 minute grace, you hear about it when jobs wait longer than roughly 10 minutes.

For backlog size, Laravel also has queue:monitor, which dispatches a QueueBusy event when a queue exceeds a job count you define:

Schedule::command('queue:monitor redis:default,redis:emails --max=500')
    ->everyMinute();

Listen for Illuminate\Queue\Events\QueueBusy and send a notification. And after every deploy, run php artisan queue:restart: workers are long-running processes and keep executing old code until they restart.

Monitoring Laravel scheduled tasks with m0nitor

Any heartbeat service that accepts HTTP pings works with the code above. In m0nitor, heartbeat monitors give each job a unique URL and alert when the expected ping does not arrive in time. You would create one for the scheduler, one per critical task and one per queue, then route alerts through the alert channels your team already uses, such as email, Slack, Telegram or PagerDuty. An HTTP monitor on the application itself complements this, since heartbeats tell you about background work and HTTP checks tell you about the site.

FAQ

Does pingOnSuccess work with runInBackground?

Yes. With runInBackground(), Laravel runs the after hooks, including pings, once the background process has finished, so the ping still reflects the end of the task. Size the grace period for the task's real runtime.

Why does my scheduled task run manually but not from cron?

Usually the environment differs: cron runs as a different user, with a minimal PATH and possibly a different PHP binary, or from a stale directory. Use absolute paths, the current symlink and run sudo -u www-data php artisan schedule:run to reproduce cron's view. Also check that the app is not in maintenance mode.

How do I monitor the scheduler on multiple servers?

Use onOneServer() for tasks that must run once, which requires a shared cache such as Redis, Memcached, DynamoDB or the database driver. For the scheduler heartbeat, give each server its own URL and do not use onOneServer(), so you notice when cron is missing on any one machine.

How do I check if Laravel queue workers are running?

On the server, supervisorctl status shows whether worker processes are up. For continuous monitoring, use a round-trip heartbeat: a scheduled job that pings a URL when a worker processes it. That catches crashed workers, restart loops and full backlogs, not just missing processes.

Product

  • Pricing
  • Features
  • Integrations
  • Security
  • Free network check

Features

  • Uptime monitoring
  • Status pages
  • Alert workflows
  • Incident management
  • Dependencies
  • AI assistant
  • Global infrastructure
  • On-call schedules
  • Enterprise SSO

Use cases

  • Websites for free
  • Cron jobs
  • SSL certificates
  • Databases
  • IP blacklists
  • GDPR compliance

Resources

  • What is a status page?
  • Documentation
  • API
  • MCP server
  • Monitoring glossary
  • Free tools
  • SLA calculator
  • SSL checker
  • DNS lookup
  • Blog
  • Changelog
  • About
  • Contact

Compare

  • Free uptime monitoring
  • UptimeRobot
  • Better Stack
  • Pingdom
  • StatusCake
  • Uptime Kuma
  • Hyperping
  • Atlassian Statuspage
  • Instatus
  • Cachet
  • Freshping