Free ServiceCronRadar is completely free for the first half of 2026!

How to Monitor Laravel Scheduled Tasks in Production

Cronradar··11 min

Your nightly database backup hasn't run in three weeks. Your subscription renewal emails stopped firing last Tuesday. Your cache cleanup task silently died after a deployment, and nobody noticed until the Redis instance ran out of memory.

These aren't hypothetical scenarios—they're the reality of running Laravel scheduled tasks in production without proper monitoring. Unlike HTTP requests that immediately show errors, scheduled tasks fail silently. There's no user refreshing a page, no error page to screenshot, no support ticket filed. The task simply... doesn't run.

This guide covers everything you need to monitor Laravel scheduled tasks effectively: native Laravel features, common production failures and how to debug them, self-hosted packages, and external monitoring services. Whether you're running a single server or a distributed deployment across multiple regions, you'll find a monitoring approach that fits.

How Laravel Task Scheduling Works (Quick Refresher)

Before diving into monitoring, let's ensure we're on the same page about how Laravel's scheduler operates.

Laravel's task scheduler lets you define all your scheduled tasks within your application code rather than managing dozens of server cron entries. You only need a single cron entry on your server:

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

This cron runs every minute, executing schedule:run which checks your defined tasks and runs any that are due.

Defining Tasks in Laravel 11+

Laravel 11 introduced a streamlined application structure that eliminates the app/Console/Kernel.php file. You now define scheduled tasks in one of two places:

Option 1: In routes/console.php (recommended for most apps)

use Illuminate\Support\Facades\Schedule;

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

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

Schedule::call(function () {
    Cache::flush();
})->weekly()->sundays()->at('03:00');

Option 2: In bootstrap/app.php (useful for complex configurations)

->withSchedule(function (Schedule $schedule) {
    $schedule->command('backup:run')->daily();
    $schedule->command('telescope:prune')->daily();
})

If you're on Laravel 10 or maintaining a legacy application, tasks are still defined in the schedule method of app/Console/Kernel.php.

Why Scheduled Tasks Fail Silently

The core problem with scheduled tasks is their fire-and-forget nature. When a task fails:

  • No HTTP response code indicates failure
  • No user witnesses the error
  • Logs may not capture the issue (especially with output suppression)
  • The scheduler moves on to the next task without alerting anyone

A task might fail for dozens of reasons: database connection timeouts, memory exhaustion, third-party API outages, file permission issues, or simply because someone forgot to add the cron entry after deploying to a new server.

This is why monitoring isn't optional—it's essential for any production Laravel application with scheduled tasks.

Laravel's Built-in Monitoring Features

Before reaching for external tools, Laravel provides several native features for monitoring and responding to task execution.

Ping URLs on Task Lifecycle Events

Laravel can ping external URLs at different points in a task's lifecycle. This is the foundation for integrating with any heartbeat-based monitoring service:

Schedule::command('backup:database')
    ->daily()
    ->pingBefore('https://monitoring.example.com/task-123/start')
    ->pingOnSuccess('https://monitoring.example.com/task-123')
    ->pingOnFailure('https://monitoring.example.com/task-123/fail');

The available ping methods are:

Method When It Fires
pingBefore($url) Before the task starts
thenPing($url) After the task completes (success or failure)
pingOnSuccess($url) Only when task succeeds
pingOnFailure($url) Only when task fails
pingBeforeIf($condition, $url) Conditionally before start
thenPingIf($condition, $url) Conditionally after completion

For tasks where you want to track execution time, use both pingBefore and pingOnSuccess—the monitoring service can calculate duration from the timestamps.

Success and Failure Callbacks

Beyond pinging URLs, you can execute arbitrary code when tasks succeed or fail:

Schedule::command('reports:generate-monthly')
    ->monthlyOn(1, '02:00')
    ->onSuccess(function () {
        Log::info('Monthly report generated successfully');
        Notification::route('slack', config('services.slack.webhook'))
            ->notify(new TaskCompletedNotification('Monthly Report'));
    })
    ->onFailure(function () {
        Log::error('Monthly report generation failed');
        Notification::route('slack', config('services.slack.webhook'))
            ->notify(new TaskFailedNotification('Monthly Report'));
    });

Important: For onFailure to trigger, your command must return a non-zero exit code. In artisan commands, return Command::FAILURE or 1:

public function handle()
{
    try {
        // Task logic here
        return Command::SUCCESS;
    } catch (\Exception $e) {
        Log::error('Task failed: ' . $e->getMessage());
        return Command::FAILURE; // This triggers onFailure()
    }
}

Output Handling

Capture task output for debugging:

// Send 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 output (only if there's output)
Schedule::command('reports:daily')
    ->daily()
    ->emailOutputOnFailure('ops@example.com');

The schedule:list Command

Laravel provides visibility into your defined tasks:

php artisan schedule:list

This outputs a table showing all scheduled tasks, their frequency, and next run time. Useful for auditing but doesn't show execution history.

Testing Tasks Locally

Before deploying, test your scheduled tasks interactively:

# Run a specific task immediately
php artisan schedule:test

# This shows a menu of all scheduled tasks - select one to run

For local development, use schedule:work instead of setting up cron:

php artisan schedule:work

This runs the scheduler in the foreground, executing tasks as they come due. It's invaluable for testing schedule timing without waiting.

Common Production Problems (and How to Fix Them)

Understanding why tasks fail helps you set up appropriate monitoring. Here are the issues that catch teams most often.

The Missing Cron Entry

The number one reason Laravel scheduled tasks don't run in production: the cron entry doesn't exist. It's embarrassingly simple but devastatingly common.

After deploying to a new server, provisioning a new environment, or migrating hosting providers, the cron entry must be manually added. It lives outside your codebase and outside version control.

Diagnosis:

# Check if cron entry exists
crontab -l

# On Laravel Forge, check the system crontab
cat /etc/crontab

Solution: Add the cron entry, and consider adding cron setup to your deployment documentation or infrastructure-as-code configuration.

PHP Path Mismatches

On shared hosting or servers with multiple PHP versions, cron might use a different PHP binary than expected:

# Your app expects PHP 8.2
/usr/local/bin/php82 artisan schedule:run

# But cron uses system default PHP 7.4
/usr/bin/php artisan schedule:run

Diagnosis:

# Check which PHP cron uses
which php

# Compare to your expected version
php -v

Solution: Use absolute paths in your cron entry:

* * * * * cd /var/www/app && /usr/local/bin/php82 artisan schedule:run >> /dev/null 2>&1

The withoutOverlapping Lock Trap

withoutOverlapping() prevents a task from running if a previous instance is still executing. It uses cache-based mutex locks—and here's the trap:

If a task crashes mid-execution or the server reboots unexpectedly, the lock persists for 24 hours by default. Your task won't run again until the lock expires.

// Default: lock expires after 24 hours
Schedule::command('import:large-dataset')
    ->hourly()
    ->withoutOverlapping();

// Better: set a reasonable expiration
Schedule::command('import:large-dataset')
    ->hourly()
    ->withoutOverlapping(30); // Lock expires after 30 minutes

Diagnosis: Tasks skip silently. Check your logs for "Skipping task" messages.

Solution: Clear the scheduler cache:

php artisan schedule:clear-cache

Critical for closures and jobs: When using withoutOverlapping() with closures or queued jobs, you must call name() first:

// WRONG - withoutOverlapping won't work correctly
Schedule::call(function () {
    // ...
})->hourly()->withoutOverlapping();

// CORRECT - name() must come before withoutOverlapping()
Schedule::call(function () {
    // ...
})->name('my-closure-task')->hourly()->withoutOverlapping();

Timezone and Daylight Saving Time Issues

Tasks scheduled around DST transitions can fire twice or skip entirely. A task scheduled for 2:30 AM might:

  • Run twice when clocks fall back (2:30 AM happens twice)
  • Never run when clocks spring forward (2:30 AM doesn't exist)

Solution: Use explicit timezones and avoid scheduling during transition hours (typically 2-3 AM):

Schedule::command('reports:daily')
    ->dailyAt('04:00')
    ->timezone('UTC'); // UTC doesn't observe DST

Or set a default timezone for all tasks in your configuration:

// config/app.php
'schedule_timezone' => 'UTC',

Memory Exhaustion

Long-running tasks processing large datasets can exhaust PHP's memory limit:

Schedule::command('export:all-users')
    ->daily()
    ->runInBackground(); // Prevents blocking other tasks

Solutions:

  • Use chunking when processing large datasets
  • Increase memory limit for specific commands
  • Use runInBackground() so memory issues don't block other tasks
  • Monitor memory usage and set up alerts

Multi-Server Scheduling Conflicts

Running the scheduler on multiple servers? Without coordination, tasks run on every server:

Schedule::command('reports:daily')
    ->daily()
    ->onOneServer(); // Only runs on one server

Requirement: onOneServer() requires a centralized cache driver (Redis or Memcached). File-based cache won't work across servers.

Monitoring Solutions: Packages and Services

Now that you understand what can go wrong, let's explore monitoring solutions that catch these failures before your users do.

Self-Hosted: spatie/laravel-schedule-monitor

Spatie's package is the most popular self-hosted solution for Laravel schedule monitoring, with over 1,300 GitHub stars.

Installation:

composer require spatie/laravel-schedule-monitor
php artisan vendor:publish --provider="Spatie\ScheduleMonitor\ScheduleMonitorServiceProvider"
php artisan migrate

What it tracks:

  • Task start and completion times
  • Execution duration
  • Memory usage
  • Failures and skip reasons

Viewing task status:

php artisan schedule-monitor:list

This shows a table of all monitored tasks with their last run status, duration, and next scheduled run.

Syncing with Oh Dear (optional):

The package integrates with Oh Dear for external alerting:

php artisan schedule-monitor:sync

This is an excellent choice if you want database-backed history without depending on external services, though you'll need to build your own alerting on top of it.

External Monitoring Services

Heartbeat-based monitoring services work on a simple principle: your task pings a URL on completion. If the ping doesn't arrive within the expected schedule plus a grace period, you get alerted.

This "dead man's switch" approach catches both task failures and infrastructure problems—if your entire server goes down, the pings stop arriving.

Healthchecks.io

The most generous free tier in the space with 20 monitors included. Open-source and self-hostable if you prefer.

Setup:

  1. Create a check at healthchecks.io with your task's schedule
  2. Copy the ping URL
  3. Add to your task:
Schedule::command('backup:database')
    ->daily()
    ->pingOnSuccess('https://hc-ping.com/your-uuid-here');

Pricing: Free for 20 monitors, $20/month for 100 monitors.

CronRadar differentiates through Laravel-specific features, including automatic discovery and syncing of your scheduled tasks—no manual monitor creation needed.

Setup:

composer require cronradar/laravel
// config/cronradar.php
return [
    'api_key' => env('CRONRADAR_API_KEY'),
    'auto_discover' => true,
];

With auto-discovery enabled, CronRadar automatically detects your scheduled tasks and creates monitors for them. When you add new tasks to your schedule, they're automatically monitored without additional configuration.

Manual integration (if you prefer explicit control):

Schedule::command('backup:database')
    ->daily()
    ->pingBefore('https://cronradar.io/ping/your-monitor-id/start')
    ->pingOnSuccess('https://cronradar.io/ping/your-monitor-id')
    ->pingOnFailure('https://cronradar.io/ping/your-monitor-id/fail');

Pricing: $1 per monitor per month with unlimited team members.

Oh Dear

Built by the team at Spatie, Oh Dear offers the deepest Laravel integration. The spatie/laravel-schedule-monitor package syncs directly with Oh Dear, automatically creating monitors for all your scheduled tasks.

Setup:

composer require spatie/laravel-schedule-monitor
// config/schedule-monitor.php
'oh_dear' => [
    'api_token' => env('OH_DEAR_API_TOKEN'),
    'site_id' => env('OH_DEAR_SITE_ID'),
],
php artisan schedule-monitor:sync

Every task in your schedule now has a corresponding monitor in Oh Dear.

Pricing: Starts around $10/month for 5 sites (each site includes multiple cron monitors).

Cronitor

A mature platform with automatic log capture, meaning you can see your task's output directly in Cronitor's dashboard without additional configuration.

Setup:

composer require cronitor/cronitor-laravel
// Cronitor auto-wraps your scheduled tasks
Schedule::command('backup:database')
    ->daily();

Pricing: $21/month for 50 monitors (Solo plan).

Better Stack (formerly Better Uptime)

Combines heartbeat monitoring with incident management, on-call scheduling, and status pages—useful if you want an all-in-one operations platform.

Setup:

Schedule::command('backup:database')
    ->daily()
    ->pingOnSuccess('https://betteruptime.com/api/v1/heartbeat/your-token');

Pricing: Free for 10 monitors (3-minute intervals), $25/month for 50 monitors with 30-second intervals.

Comparison Table

Service Free Tier Paid Starting Laravel Integration Best For
Healthchecks.io 20 monitors $20/mo (100) Native ping methods Budget-conscious teams
CronRadar $1/monitor/mo Auto-discovery package Automated Laravel setup
Oh Dear ~$10/mo (5 sites) Spatie package sync Laravel-native experience
Cronitor Limited $21/mo (50) Official package Log capture needs
Better Stack 10 monitors $25/mo (50) Native ping methods All-in-one ops platform
spatie/schedule-monitor Unlimited Native Self-hosted preference

Environment-Specific Configurations

Different hosting environments require different approaches to running and monitoring scheduled tasks.

Laravel Forge

Forge automatically adds the scheduler cron entry when you enable it in the server settings. Find it under Server → Scheduler.

Key points:

  • Forge uses the system crontab (/etc/crontab), not user crontabs
  • Running crontab -l won't show Forge-managed cron entries
  • Manual task runs in Forge timeout after 60 seconds
  • Enable Heartbeat monitoring in Forge for basic "scheduler is running" alerts

Laravel Vapor

Vapor doesn't use cron at all. Instead, it leverages AWS EventBridge to trigger your scheduled tasks.

Vapor-specific considerations:

# vapor.yml
environments:
  production:
    cli-timeout: 120  # Required for sub-minute tasks
    cli-concurrency: 2  # Required for sub-minute tasks

Critical limitation: Log output from scheduled tasks doesn't appear in CloudWatch. If you need visibility into task output, dispatch a queued job from your scheduled task—job logs do appear in CloudWatch.

// Instead of logging directly in the scheduled command
Schedule::call(function () {
    ProcessReports::dispatch(); // This job's logs will appear in CloudWatch
})->hourly();

Docker Environments

Docker containers don't run cron daemons by default. You have several options:

Option 1: Use schedule:work (simplest)

CMD ["php", "artisan", "schedule:work"]

This runs the scheduler in the foreground, checking for due tasks every minute.

Option 2: Supercronic (Docker-friendly cron)

# Install supercronic
ADD https://github.com/aptible/supercronic/releases/download/v0.2.1/supercronic-linux-amd64 /usr/local/bin/supercronic
RUN chmod +x /usr/local/bin/supercronic

# Add crontab file
COPY crontab /etc/crontab

CMD ["supercronic", "/etc/crontab"]

Option 3: Separate scheduler container

Run your scheduler as a dedicated container in your docker-compose setup:

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

Shared Hosting

If your host doesn't allow shell access for cron, you may need to trigger the scheduler via HTTP:

// routes/web.php (protected by secret token)
Route::get('/scheduler/run', function (Request $request) {
    if ($request->token !== config('app.scheduler_token')) {
        abort(403);
    }
    Artisan::call('schedule:run');
    return 'OK';
})->name('scheduler.run');

Then use an external cron service (like cron-job.org or EasyCron) to hit this URL every minute.

Security warning: Always protect this endpoint with a secret token and consider IP restrictions.

Building a Monitoring Strategy

Here's a practical approach to implementing scheduled task monitoring:

Step 1: Inventory Your Tasks

Start by listing all your scheduled tasks and their criticality:

php artisan schedule:list

Categorize each task:

  • Critical: Business-impacting if missed (billing, backups, sync jobs)
  • Important: Should run but missing one isn't catastrophic
  • Nice-to-have: Cleanup tasks, cache warming

Step 2: Choose Your Monitoring Approach

For most Laravel applications, a combination works best:

  • Native callbacks for logging and immediate Slack/email alerts
  • External heartbeat service for critical tasks
  • Self-hosted package for execution history and debugging

Step 3: Implement Graduated Alerting

Not every missed task needs to wake someone up at 3 AM:

// Critical: External monitoring + immediate alerts
Schedule::command('billing:process-renewals')
    ->daily()
    ->pingOnSuccess('https://cronradar.io/ping/billing-critical')
    ->pingOnFailure('https://cronradar.io/ping/billing-critical/fail')
    ->onFailure(function () {
        // Immediate PagerDuty/Opsgenie alert
        PagerDuty::trigger('Billing renewal processing failed');
    });

// Important: External monitoring, alert during business hours
Schedule::command('reports:generate')
    ->daily()
    ->pingOnSuccess('https://cronradar.io/ping/reports');

// Nice-to-have: Log-based monitoring only
Schedule::command('cache:warm')
    ->hourly()
    ->onFailure(fn () => Log::warning('Cache warming failed'));

Step 4: Test Your Monitoring

Before relying on your monitoring in production:

  1. Intentionally fail a task and verify alerts fire
  2. Stop the scheduler and confirm the heartbeat service alerts
  3. Review that log output is being captured correctly
  4. Test that team members receive notifications

Step 5: Document and Maintain

Create runbooks for common failures:

  • How to clear scheduler cache locks
  • How to manually run missed tasks
  • Escalation paths for different task failures
  • Recovery procedures for data-dependent tasks

Troubleshooting Checklist

When a scheduled task isn't running, work through this checklist:

1. Is the cron entry present?

crontab -l
# On Forge: cat /etc/crontab

2. Is the scheduler running at all?

# Check recent runs
grep "schedule:run" /var/log/syslog | tail -20

3. Is your specific task defined correctly?

php artisan schedule:list
# Verify your task appears with correct timing

4. Can the task run manually?

php artisan your:command
# If this fails, the issue is with the command, not scheduling

5. Is there a stale lock?

php artisan schedule:clear-cache

6. Are environment variables available to cron?

# Add to crontab to load environment
* * * * * cd /path && source .env && php artisan schedule:run

7. Check permissions and paths

# Verify PHP path
which php
# Verify project path ownership
ls -la /path/to/project

Conclusion

Monitoring Laravel scheduled tasks isn't optional—it's a fundamental part of running a production application. The good news is Laravel provides solid native tools, and the ecosystem offers excellent packages and services to fill the gaps.

Start with Laravel's built-in ping methods and callbacks for immediate visibility. Add a heartbeat monitoring service for critical tasks that need external verification. Consider a package like spatie/laravel-schedule-monitor if you want execution history without external dependencies.

The specific tools matter less than having some monitoring in place. A failed backup you learn about immediately is recoverable. A failed backup you discover three weeks later when you need it is a disaster.

Pick an approach that matches your team's needs and budget, implement it consistently across all your scheduled tasks, and test that alerts actually reach the right people. Your future self—the one not debugging a silent failure at 2 AM—will thank you.

Start monitoring your cron jobs

Get started in minutes. No credit card required.