Back to Blog
Updated April 2026

How to Monitor Laravel Cron Jobs (With Alerts)

Laravel's task scheduler is elegant. Define your jobs in app/Console/Kernel.php, add one cron entry to call schedule:run, and you're done. Except for one problem: when those scheduled tasks fail, Laravel doesn't tell anyone.

Your nightly database backup? It crashed three weeks ago. The invoice generation that runs every Monday? Broken since someone updated a dependency. You won't know until a customer asks where their invoice is, or worse, until you need that backup.

This guide covers everything: how Laravel's task scheduling works, why scheduled tasks fail, how to set up monitoring with CronSignal, and how to fix the most common issues that break Laravel cron jobs in production.

This guide covers Laravel 9, 10, and 11. For Laravel 8 and earlier, see notes inline.

How Laravel's Task Scheduling Works

Laravel's scheduler replaces the need for multiple crontab entries (use our cron expression generator to build the right schedule). Instead of adding one cron entry per task, you define all your scheduled tasks in PHP and add a single cron entry that runs every minute:

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

Every minute, schedule:run checks which tasks are due and executes them. The tasks themselves are defined in app/Console/Kernel.php (Laravel 9-10) or in a service provider via Schedule facade (Laravel 11+):

// app/Console/Kernel.php (Laravel 9-10)

use Illuminate\Console\Scheduling\Schedule;

protected function schedule(Schedule $schedule)
{
    $schedule->command('backup:run')
        ->dailyAt('02:00');

    $schedule->command('reports:generate')
        ->weeklyOn(1, '09:00');  // Every Monday at 9am

    $schedule->command('invoices:send')
        ->monthlyOn(1, '08:00');

    $schedule->command('cache:prune-stale-tags')
        ->hourly();
}
// routes/console.php (Laravel 11+)

use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:run')
    ->dailyAt('02:00');

Schedule::command('reports:generate')
    ->weeklyOn(1, '09:00');

You can schedule artisan commands, closure-based tasks, queued jobs, and even shell commands. Laravel handles overlapping, output capture, maintenance mode behavior, and timezones.

Why Laravel Cron Jobs Fail

Despite the elegant API, Laravel scheduled tasks fail in production more often than you'd expect. Here are the most common reasons:

  • schedule:run not in crontab -- The single most common issue. After a server migration, reimage, or Docker rebuild, someone forgets to add the cron entry. No cron entry means no tasks run at all, and there's nothing to generate an error.
  • Wrong PHP or artisan path -- If the crontab uses php but the server needs /usr/bin/php8.2, or if the path to the project is wrong after a deployment, the command fails silently (if your job isn't running at all, check that guide first).
  • Maintenance mode blocking tasks -- By default, php artisan down stops all scheduled tasks from running. If you put the app in maintenance mode for a deployment and forget to bring it back up, your cron jobs silently stop.
  • Queue worker is dead -- If your scheduled task dispatches a job to the queue (using $schedule->job()), the task "succeeds" when the job is dispatched, not when it's processed. If your queue worker has crashed, the job sits in the queue forever.
  • Dependency or config changes -- A composer update breaks a package your task depends on. An environment variable gets deleted. A database migration changes a column name. The task starts throwing exceptions that nobody sees.
  • Server reboot or cron daemon crash -- If the server reboots and the cron service doesn't start automatically, or if systemd's cron unit fails, nothing runs and nothing errors.

The common thread: Laravel logs task output to storage/logs/laravel.log by default. That's fine for debugging, but it requires you to actively check the logs. And if the task never runs at all, there's nothing to log.

How to Set Up Laravel Scheduled Tasks

If you already have your tasks set up, skip to the monitoring section. Here's a quick reference for defining scheduled tasks.

Artisan Command Tasks

The most common approach. Create a command and schedule it:

// Create the command:
// php artisan make:command BackupDatabase

// app/Console/Commands/BackupDatabase.php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class BackupDatabase extends Command
{
    protected $signature = 'backup:database';
    protected $description = 'Backs up the database to S3';

    public function handle()
    {
        $this->info('Starting database backup...');

        // Your backup logic
        $filename = storage_path('backups/db-' . now()->format('Y-m-d') . '.sql');
        $db = config('database.connections.mysql');

        exec(sprintf(
            'mysqldump -h %s -u %s -p%s %s > %s',
            $db['host'], $db['username'], $db['password'], $db['database'], $filename
        ));

        // Upload to S3
        Storage::disk('s3')->put(
            'backups/' . basename($filename),
            file_get_contents($filename)
        );

        $this->info('Backup completed: ' . basename($filename));
        return Command::SUCCESS;
    }
}

Then schedule it in Kernel.php:

protected function schedule(Schedule $schedule)
{
    $schedule->command('backup:database')
        ->dailyAt('02:00')
        ->withoutOverlapping()
        ->runInBackground();
}

Closure-Based Tasks

For simple tasks that don't need a full command class:

$schedule->call(function () {
    // Clean up expired sessions
    DB::table('sessions')
        ->where('last_activity', '<', now()->subDays(7))
        ->delete();
})->daily();

Queued Job Tasks

Dispatch a queued job on a schedule:

$schedule->job(new ProcessDailyReports)
    ->dailyAt('06:00');

Shell Command Tasks

$schedule->exec('node /home/forge/script.js')
    ->daily();

The Solution: Heartbeat Monitoring

The pattern is simple: after your task completes successfully, ping an external URL. If that ping doesn't arrive on schedule, you get an alert.

This catches every failure mode. Task crashed mid-execution? No ping, you get alerted. Server went down? No ping. Crontab deleted? No ping. The monitoring service doesn't care why the ping didn't arrive. It just knows something's wrong.

Monitoring Laravel Tasks With CronSignal

Laravel makes this easy with built-in methods. Here are the approaches, from simplest to most flexible.

Method 1: pingOnSuccess (Recommended)

The cleanest approach. Laravel has native support for pinging URLs after successful task completion:

// app/Console/Kernel.php

protected function schedule(Schedule $schedule)
{
    $schedule->command('backup:run')
        ->dailyAt('02:00')
        ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
}

That's it. If backup:run completes with exit code 0, Laravel pings the URL. If it fails or throws an exception, no ping is sent.

You can also ping on failure if you want redundant alerting:

$schedule->command('backup:run')
    ->daily()
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID')
    ->pingOnFailure('https://api.cronsignal.io/ping/YOUR_CHECK_ID/fail');

Note: pingOnSuccess() and pingOnFailure() require the guzzlehttp/guzzle package. It's included by default in Laravel, but if you've removed it, install it with composer require guzzlehttp/guzzle.

Method 2: Using then() Callbacks

If you need more control over what happens after a task completes, use the then() callback:

use Illuminate\Support\Facades\Http;

$schedule->command('reports:generate')
    ->hourly()
    ->then(function () {
        Http::timeout(5)->get('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
    });

This runs your custom code after the task succeeds. Useful if you need to include additional data or conditional logic:

$schedule->command('sync:inventory')
    ->hourly()
    ->then(function () {
        $count = DB::table('products')->where('synced_at', '>', now()->subHour())->count();
        Http::timeout(5)->post('https://api.cronsignal.io/ping/YOUR_CHECK_ID', [
            'body' => "Synced {$count} products",
        ]);
    });

Method 3: Closure-Based Tasks

For tasks defined as closures rather than commands:

$schedule->call(function () {
    // Your task logic here
    SyncService::run();
})->daily()->then(function () {
    file_get_contents('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

The then() callback only fires if the closure completes without throwing an exception.

Method 4: Output Capture Combined With Curl

If you want to send the task's output to CronSignal for debugging, use sendOutputTo() combined with an after() callback:

$schedule->command('backup:database')
    ->dailyAt('02:00')
    ->sendOutputTo(storage_path('logs/backup-output.log'))
    ->after(function () {
        $output = file_get_contents(storage_path('logs/backup-output.log'));
        Http::timeout(5)->withBody($output, 'text/plain')
            ->post('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
    });

This sends the command's full output (stdout) to CronSignal, so when something goes wrong you can see exactly what happened without SSH-ing into the server.

Monitor your Laravel cron jobs for free

Get alerted the moment a job misses its schedule. Takes 30 seconds to set up.

Sign up with Google
or

Monitoring the Scheduler Itself

Here's something most tutorials miss: you should also monitor that schedule:run is actually being called. This is the meta-problem -- if the cron entry that runs schedule:run gets deleted, or the cron daemon itself stops, none of your scheduled tasks run. And since no task runs, no heartbeat pings are sent, and you get alerts for every single task at once.

The fix is a dedicated heartbeat task that proves the scheduler is alive:

$schedule->call(function () {
    // Empty task, just proves the scheduler is alive
})->everyMinute()
    ->pingOnSuccess('https://api.cronsignal.io/ping/SCHEDULER_HEARTBEAT');

Set up a CronSignal check to expect this ping every minute with a 5-minute grace period. If it stops arriving, your entire scheduler is down -- not just one task.

This is the single most important monitor you can add. It catches server reboots, cron daemon crashes, Docker container restarts, and accidental crontab deletions.

What If schedule:run Isn't Running?

If you get an alert that the scheduler heartbeat is missing, check these in order:

  1. Is the cron entry present? Run crontab -l to verify.
  2. Is the cron daemon running? Check with systemctl status cron (or crond on CentOS/RHEL).
  3. Can PHP execute artisan? Run cd /path-to-project && php artisan schedule:run manually to check for errors.
  4. Is the app in maintenance mode? Check with php artisan up.
  5. Are there permission issues? Cron runs as a specific user -- make sure that user can read the project files and write to storage/.

Handling Long-Running Tasks

For tasks that take significant time (ETL pipelines, large data exports, report generation), you want to know if they start but never finish. Use before() and pingOnSuccess() together:

use Illuminate\Support\Facades\Http;

$schedule->command('etl:run')
    ->daily()
    ->before(function () {
        Http::timeout(5)->get('https://api.cronsignal.io/ping/YOUR_CHECK_ID/start');
    })
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

This pings when the task starts and again when it completes. If CronSignal gets a start ping but no completion ping within the expected runtime, the task is hanging or crashed mid-execution.

For tasks that might legitimately take a long time, use withoutOverlapping() to prevent multiple instances from stacking up:

$schedule->command('etl:run')
    ->daily()
    ->withoutOverlapping(120)  // Lock expires after 120 minutes
    ->before(function () {
        Http::timeout(5)->get('https://api.cronsignal.io/ping/YOUR_CHECK_ID/start');
    })
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

Queue Workers vs Scheduled Tasks

Don't confuse these. Laravel's scheduler runs discrete tasks on a schedule. Queue workers process jobs continuously from a queue.

For scheduled tasks that dispatch jobs to the queue, monitor the scheduled task itself, not the queued job:

$schedule->job(new ProcessReportsJob)
    ->daily()
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

This confirms the job was dispatched to the queue. But if the queue worker has crashed, the job will sit there unprocessed. You need separate monitoring for queue health.

A simple approach: schedule a task that checks queue health:

$schedule->call(function () {
    $pendingJobs = DB::table('jobs')->count();
    $oldestJob = DB::table('jobs')->min('created_at');

    // Alert if jobs are piling up (more than 100 pending
    // or oldest job is more than 30 minutes old)
    if ($pendingJobs > 100 || ($oldestJob && now()->diffInMinutes($oldestJob) > 30)) {
        Log::warning("Queue backlog: {$pendingJobs} pending, oldest: {$oldestJob}");
    }
})->everyFiveMinutes()
    ->pingOnSuccess('https://api.cronsignal.io/ping/QUEUE_HEALTH_CHECK');

Common Issues With Laravel Cron Jobs

These are the issues we see most often when helping developers debug their Laravel scheduled tasks.

Wrong PHP Path in Crontab

The most frequent cause of "my cron jobs aren't running." Different servers have PHP installed in different locations, and the php command might point to a different version than your app needs.

Wrong:

* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1

Right:

# Use the full path to the correct PHP version:
* * * * * cd /var/www/myapp && /usr/bin/php8.2 artisan schedule:run >> /dev/null 2>&1

Find the correct path with which php or which php8.2 on your server.

Storage Permissions

Laravel's scheduler writes lock files to storage/framework/ for withoutOverlapping() and cache files for various features. If the cron user can't write to the storage directory, tasks fail silently.

# Fix permissions:
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache

This is especially common after deployments that create new files owned by the deploy user instead of the web server user.

.env File Not Loaded

If your .env file is missing or unreadable by the cron user, all config values fall back to defaults. Database credentials, API keys, queue connections -- everything breaks.

# Verify .env exists and is readable:
ls -la /var/www/myapp/.env

# Test by running artisan as the cron user:
sudo -u www-data php artisan schedule:run

Maintenance Mode Blocking Tasks

When you run php artisan down, all scheduled tasks stop by default. If you need certain tasks to run during maintenance, use evenInMaintenanceMode():

$schedule->command('backup:database')
    ->dailyAt('02:00')
    ->evenInMaintenanceMode()
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

Timezone Confusion

By default, Laravel uses the timezone set in config/app.php. If your server is in UTC but your app config says America/New_York, tasks run at unexpected times. Be explicit:

$schedule->command('reports:daily')
    ->dailyAt('09:00')
    ->timezone('America/New_York')
    ->pingOnSuccess('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

Output Redirect Hiding Errors

The standard crontab entry redirects all output to /dev/null:

* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1

This hides errors. While debugging, redirect to a log file instead:

* * * * * cd /path && php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1

Once everything is working, you can switch back to /dev/null or keep the log file for auditing.

Testing Your Setup

Before relying on monitoring, verify it works.

First, run a task manually and confirm the ping arrives:

# Laravel 9+ only
php artisan schedule:test
# Select your task from the list

# For Laravel 8 and earlier, run the command directly:
php artisan backup:run

Check CronSignal to see the ping registered.

Then test failure alerting. Comment out the task logic temporarily and replace it with a thrown exception. Run it again. The ping shouldn't arrive, and you should get an alert within your configured grace period.

Finally, test the scheduler heartbeat. Stop the cron daemon temporarily (sudo systemctl stop cron) and verify you get an alert within a few minutes. Don't forget to start it again.

Getting Started

CronSignal handles the monitoring side of this for $5/month with unlimited checks. Create a check, grab your ping URL, add it to your scheduled task, and you're done. Start with 3 checks free.

The setup takes two minutes. The peace of mind when your 2 AM backup actually fails and you find out at 2:15 AM instead of next month? That's worth it.


For more on the general pattern of heartbeat monitoring and why it beats other approaches, see our guide on how to monitor cron jobs.

Explore More

Ready to monitor your cron jobs?

$5/month, unlimited monitors

Sign up with Google
or

Related Resources