How to Monitor Laravel Scheduled Tasks in Production
Updated
Contents
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:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1Each minute, schedule:run checks your task definitions and runs the ones that are due:
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.
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:
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:
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#
// 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('[email protected]');These output methods work only with command and exec, and emailing requires a configured mailer.
Inspect and test the schedule#
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, locallyschedule: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 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,.envis no longer read, andenv()calls outside configuration files returnnull. Read settings throughconfig(). - Variables you export in a shell profile are not in cron's environment. Put them in
.envor 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 pattern.
CronRadar over HTTP#
The cronradar/laravel package, which discovers scheduled tasks automatically, is temporarily unavailable on Packagist. The Laravel integration guide 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:
// 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
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:
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:
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: 10 active monitors free, then $2 Fast, $1 Frequent, or $0.50 Standard per monitor each month by schedule. The Laravel overview summarizes coverage.
Other options#
| Option | How it connects to Laravel | Where alerts come from |
|---|---|---|
| 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 | A heartbeat URL per Forge scheduled job | Forge |
| Healthchecks.io | Ping URLs, including /start and /fail, used with pingBefore, pingOnSuccess, and pingOnFailure |
Healthchecks.io |
| Better Stack heartbeats | A heartbeat URL, with /fail for failures, used with the same ping methods |
Better Stack |
| Cronitor | 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 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.
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.
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:
services:
scheduler:
image: your-laravel-app
command: php artisan schedule:work
depends_on:
- appIf you prefer a crontab, 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:
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"]* * * * * php /var/www/html/artisan schedule:runShared 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 covers which PHP binary to call and how to stop cron emails.
A Monitoring Strategy#
- Inventory the tasks. Run
php artisan schedule:listand mark each task as critical (billing, backups, data sync), important, or routine. - Make failures visible. Commands return a non-zero exit code on failure, closures throw, and output goes to a log file.
- Monitor critical tasks externally. Report start, success, and failure, and add a scheduler-wide heartbeat.
- Route alerts by urgency. Not every missed cache warm-up needs to page someone at 3 AM; a missed billing run might.
- 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.
- 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 for its current status. Until it returns, report each task over HTTP as shown above, with its own key and schedule.
Put this guide to work
Connect your Laravel job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Laravel SDK is temporarily unavailable on Packagist. Use manual HTTP reporting below. This setup does not discover scheduled tasks automatically.