# How to Monitor Laravel Scheduled Tasks in Production

By Dan Ochoa · Published 2025-12-03 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/laravel-cron-monitoring

> Monitor Laravel scheduled tasks in production: catch failed, skipped, and missed runs with scheduler hooks, output logs, and external heartbeat checks.

**To monitor Laravel scheduled tasks in production, confirm the server runs `schedule:run` every minute, make each task exit non-zero when it fails, and report each task's start, success, and failure to a monitor that alerts when an expected run doesn't arrive.** Laravel's `onFailure` hooks and ping methods report what happens inside `schedule:run`. They cannot report a run that never started because the cron entry is missing, the application is in maintenance mode, or the server is down.

This guide covers Laravel 11 and later, where tasks are defined in `routes/console.php`. On Laravel 10, the same methods are called on `$schedule` in `app/Console/Kernel.php`.

## How the Laravel Scheduler Runs

The server needs one cron entry that runs `schedule:run` every minute:

```bash
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
```

Each minute, `schedule:run` checks your task definitions and runs the ones that are due:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send-daily-digest')
    ->dailyAt('08:00')
    ->timezone('America/New_York');

Schedule::job(new ProcessSubscriptionRenewals)
    ->hourly()
    ->withoutOverlapping();
```

You can also define tasks with `withSchedule` in `bootstrap/app.php`. Tasks due in the same minute run one after another in the order they are defined, unless you use `runInBackground()`. See the [Laravel task scheduling documentation](https://laravel.com/docs/13.x/scheduling).

Nothing in this chain reports a failure on its own. A task that throws writes to your log, if logging is configured, and the next task runs. A missing cron entry produces no log at all.

## Laravel's Built-In Monitoring Features

### Make failures exit non-zero

Laravel decides whether a task failed from its exit code. For `Schedule::command()` and `Schedule::exec()`, a non-zero exit code is a failure. A scheduled closure that throws also counts as failed, and the exception is rethrown after the hooks run.

A command that catches its own exception and returns success hides the failure from everything below. Report the exception and return `Command::FAILURE`:

```php
public function handle(): int
{
    try {
        // Task logic here
        return Command::SUCCESS;
    } catch (\Throwable $e) {
        report($e);

        return Command::FAILURE; // onFailure() hooks run
    }
}
```

### Task hooks

`before` and `after` run around every execution. `onSuccess` and `onFailure` run depending on the exit code, and can receive the command's output:

```php
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;
use Illuminate\Support\Stringable;

Schedule::command('reports:generate-monthly')
    ->monthlyOn(1, '02:00')
    ->onSuccess(function () {
        Log::info('Monthly report generated');
    })
    ->onFailure(function (Stringable $output) {
        Log::error('Monthly report failed', ['output' => (string) $output]);
    });
```

Hooks run inside the same application that just failed. If the failure is a broken database or mail configuration, a notification sent from `onFailure` can fail with it.

### Ping URLs

Laravel can request a URL at points in a task's lifecycle, which is how most heartbeat services connect to the scheduler:

| Method                                   | When it fires                                |
| ---------------------------------------- | -------------------------------------------- |
| `pingBefore($url)`                       | Before the task starts                       |
| `thenPing($url)`                         | After the task finishes, whatever the result |
| `pingOnSuccess($url)`                    | Only when the task succeeds                  |
| `pingOnFailure($url)`                    | Only when the task fails                     |
| `pingBeforeIf()`, `thenPingIf()`         | Conditionally, before or after               |
| `pingOnSuccessIf()`, `pingOnFailureIf()` | Conditionally, on success or failure         |

These methods send a GET request through Guzzle. Unless you bind your own client, Laravel uses a 10-second connect timeout and a 30-second request timeout, and it reports a failed ping to the exception handler instead of failing the task.

### Task output

```php
// Write output to a file
Schedule::command('process:payments')
    ->daily()
    ->sendOutputTo('/var/log/laravel-scheduler/payments.log');

// Append instead of overwrite
Schedule::command('process:payments')
    ->daily()
    ->appendOutputTo('/var/log/laravel-scheduler/payments.log');

// Email the output only when the command exits with a non-zero code
Schedule::command('reports:daily')
    ->daily()
    ->emailOutputOnFailure('ops@example.com');
```

These output methods work only with `command` and `exec`, and emailing requires a configured mailer.

### Inspect and test the schedule

```bash
php artisan schedule:list   # every task and its next due time
php artisan schedule:test   # choose a task and run it now
php artisan schedule:work   # run the scheduler in the foreground, locally
```

`schedule:list` shows what is defined, not what ran. For a record of executions, listen for the scheduler's events: `ScheduledTaskStarting`, `ScheduledTaskFinished`, `ScheduledBackgroundTaskFinished`, `ScheduledTaskSkipped`, and `ScheduledTaskFailed`, all in `Illuminate\Console\Events`.

## Why Tasks Stop Running

When a task doesn't run at all, the cause is usually the cron entry, the PHP binary, a schedule Laravel never loads, a filter, a stale `withoutOverlapping` lock, several servers, or an earlier task that hangs. [Laravel schedule not running](https://cronradar.com/blog/laravel-schedule-not-running) works through each one with the command that confirms it. Two problems matter to monitoring itself:

### Schedule::job only dispatches

`Schedule::job()` puts a job on the queue. Its success hooks mean the job was queued, not that a worker finished it. If no queue worker is running, the scheduler still reports success. Report completion from the job's own `handle()` method instead.

### Configuration and environment variables

When `artisan` runs from the project directory, Laravel loads `.env` itself; cron doesn't need to. Two things still go wrong:

- After `php artisan config:cache`, `.env` is no longer read, and `env()` calls outside configuration files return `null`. Read settings through `config()`.
- Variables you export in a shell profile are not in cron's environment. Put them in `.env` or your deployment's secret configuration.

## External Monitoring

Hooks and logs describe runs that happened. To learn about a run that didn't, use a monitor outside the server: each task reports when it starts and finishes, and the monitor alerts when a report fails to arrive on schedule. This is the [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) pattern.

### CronRadar over HTTP

The `cronradar/laravel` package, which discovers scheduled tasks automatically, is temporarily unavailable on Packagist. The [Laravel integration guide](https://docs.cronradar.com/integrations/laravel) carries its current availability notice. Until it returns, report each task over HTTP. No package is needed, but nothing is discovered automatically: each task needs its own monitor key and its real schedule.

Store the API key in your deployment's secret configuration as `CRONRADAR_API_KEY`, use a separate CronRadar application and key for each environment, and expose it through `config/services.php` so it works with a cached configuration:

```php
// config/services.php
'cronradar' => [
    'key' => env('CRONRADAR_API_KEY'),
],
```

A small helper sends each report with a five-second timeout and Basic authentication, and never lets a monitoring error reach the task:

```php
<?php

namespace App\Support;

use Illuminate\Support\Facades\Http;
use Throwable;

class CronRadar
{
    public static function ping(string $monitorKey, string $event = '', array $query = []): void
    {
        $apiKey = config('services.cronradar.key');

        if (! $apiKey) {
            return;
        }

        $url = 'https://cron.life/api/v1/ping/'.rawurlencode($monitorKey)
            .($event !== '' ? '/'.$event : '')
            .($query ? '?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '');

        try {
            Http::timeout(5)->withBasicAuth($apiKey, '')->send('POST', $url);
        } catch (Throwable $e) {
            report($e); // monitoring problems never fail the task
        }
    }
}
```

Attach it to a task with the scheduler hooks. The start report carries the schedule, so CronRadar creates the monitor the first time it sees the key:

```php
use App\Support\CronRadar;
use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:database')
    ->dailyAt('02:00')
    ->before(fn () => CronRadar::ping('database-backup', 'start', ['schedule' => '0 2 * * *']))
    ->onSuccess(fn () => CronRadar::ping('database-backup', 'complete'))
    ->onFailure(fn () => CronRadar::ping('database-backup', 'fail', [
        'message' => 'backup:database exited with a non-zero code',
    ]));
```

CronRadar evaluates schedules in UTC. `dailyAt('02:00')` matches `0 2 * * *` when the application time zone is UTC; if the task uses another zone, convert its time. Keep the failure message short and generic. It is limited to 1,000 characters and must not contain secrets, logs, or job data.

A failure report opens an incident immediately. A start without a completion, and a run that never arrives, are caught against the schedule. To find out quickly when the scheduler itself stops, for example after a server rebuild without the cron entry, add a scheduler-wide heartbeat. Define it before your other tasks so a slow task can't delay it:

```php
Schedule::call(fn () => CronRadar::ping('laravel-scheduler', '', ['schedule' => '*/5 * * * *']))
    ->everyFiveMinutes()
    ->name('cronradar-scheduler-heartbeat');
```

Choose alert destinations in the application's settings: email, Slack, webhook, Discord, or PagerDuty. Then run the real task once, confirm its result appears in CronRadar, and confirm a test alert reaches the person who will act on it. Pricing is on the [pricing page](https://cronradar.com/pricing): 10 active monitors free, then $2 Fast, $1 Frequent, or $0.50 Standard per monitor each month by schedule. The [Laravel overview](https://cronradar.com/laravel) summarizes coverage.

### Other options

| Option                                                                                     | How it connects to Laravel                                                                                           | Where alerts come from                                    |
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| [spatie/laravel-schedule-monitor](https://github.com/spatie/laravel-schedule-monitor)      | Logs each task's start, finish, failure, and skip to your database; run `schedule-monitor:sync` on each deploy       | Oh Dear, when connected; the package itself doesn't alert |
| [Laravel Forge heartbeats](https://forge.laravel.com/docs/resources/scheduler)             | A heartbeat URL per Forge scheduled job                                                                              | Forge                                                     |
| [Healthchecks.io](https://healthchecks.io/docs/http_api/)                                  | Ping URLs, including `/start` and `/fail`, used with `pingBefore`, `pingOnSuccess`, and `pingOnFailure`              | Healthchecks.io                                           |
| [Better Stack heartbeats](https://betterstack.com/docs/uptime/cron-and-heartbeat-monitor/) | A heartbeat URL, with `/fail` for failures, used with the same ping methods                                          | Better Stack                                              |
| [Cronitor](https://github.com/cronitorio/cronitor-laravel)                                 | The `cronitor/laravel` package creates monitors from your schedule with `cronitor:sync` and reports scheduler events | Cronitor                                                  |

The spatie package also records memory use and run time, and stores history you can query without an external service. [Better Stack heartbeats vs a dedicated cron monitor](https://cronradar.com/blog/better-stack-heartbeats) explains how its period, grace, and responder-license pricing work. Compare plans on each provider's own pricing page; they change often.

## Hosting Environments

### Laravel Forge

Forge's Laravel integration configures the scheduler entry for a site. Jobs you start with the "Run" option in Forge have a 60-second limit; scheduled runs through cron don't. Forge can also monitor a scheduled job with heartbeats, notifying you when the job's URL isn't pinged within the time you set. See [Forge's scheduler documentation](https://forge.laravel.com/docs/resources/scheduler).

### Laravel Vapor

Vapor has no server crontab. It invokes the scheduler through an AWS EventBridge rule that runs `schedule:run` in the CLI function. EventBridge fires somewhere within each minute rather than at second zero, so sub-minute tasks need `scheduler: sub-minute` in `vapor.yml`, a `cli-timeout` of at least 120 seconds, a `cli-concurrency` of at least 2, and Vapor Core 2.34.0 or later. See [Vapor's sub-minute scheduling announcement](https://laravel.com/blog/vapor-sub-minute-scheduled-tasks).

### Docker

Containers don't run a cron daemon unless you add one. The simplest option is a separate container that runs the scheduler in the foreground:

```yaml
services:
  scheduler:
    image: your-laravel-app
    command: php artisan schedule:work
    depends_on:
      - app
```

If you prefer a crontab, [supercronic](https://github.com/aptible/supercronic) is a cron runner built for containers. Pin a release and verify its checksum, as its release notes show. This is the v0.2.50 snippet, published October 16, 2026:

```dockerfile
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.50/supercronic-linux-amd64 \
    SUPERCRONIC_SHA1SUM=0795da10bf6303e9680a2c2bd8dd6b9aa33ebda2 \
    SUPERCRONIC=supercronic-linux-amd64

RUN curl -fsSLO "$SUPERCRONIC_URL" \
 && echo "${SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}" | sha1sum -c - \
 && chmod +x "$SUPERCRONIC" \
 && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
 && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic

COPY scheduler.crontab /app/scheduler.crontab
CMD ["supercronic", "/app/scheduler.crontab"]
```

```text
* * * * * php /var/www/html/artisan schedule:run
```

### Shared hosting

Most shared hosts expose cron in their control panel. Use the same single `schedule:run` entry with the host's absolute PHP path, and check the host's documentation for the minimum interval it allows. On cPanel, [cPanel cron jobs](https://cronradar.com/blog/cpanel-cron-jobs) covers which PHP binary to call and how to stop cron emails.

## A Monitoring Strategy

1. **Inventory the tasks.** Run `php artisan schedule:list` and mark each task as critical (billing, backups, data sync), important, or routine.
2. **Make failures visible.** Commands return a non-zero exit code on failure, closures throw, and output goes to a log file.
3. **Monitor critical tasks externally.** Report start, success, and failure, and add a scheduler-wide heartbeat.
4. **Route alerts by urgency.** Not every missed cache warm-up needs to page someone at 3 AM; a missed billing run might.
5. **Test the alerts.** Stop the scheduler in a test environment and confirm the missed-run alert arrives, then confirm a failure alert from a harmless test task.
6. **Write runbooks** for clearing stale overlap locks, running a missed task by hand, and recovering tasks that depend on earlier data.

## Frequently Asked Questions

### How do I know if a Laravel scheduled task failed?

Laravel treats a non-zero exit code from a command, or an exception from a closure, as a failure and runs `onFailure` hooks and `pingOnFailure` URLs. For tasks that never start, you need an external monitor that expects a report on the task's schedule.

### Does onFailure run when a scheduled command throws an exception?

Yes. An uncaught exception makes an Artisan command exit with a non-zero code, which Laravel treats as a failure. A command that catches the exception and returns `Command::SUCCESS` hides the failure, so return `Command::FAILURE` instead.

### Can CronRadar discover Laravel scheduled tasks automatically?

Not at the moment. The `cronradar/laravel` package that does this is temporarily unavailable on Packagist; see the [integration guide](https://docs.cronradar.com/integrations/laravel) for its current status. Until it returns, report each task over HTTP as shown above, with its own key and schedule.
