Back to blog
PHP

Laravel Task Scheduling Monitoring: Catch Silent Failures

Matt8 min read

The Invoice Job That Stopped Running for Two Weeks

TL;DR: Laravel scheduled commands fail silently when the system cron that runs Artisan schedule:run stops, queued commands throw exceptions after dispatch, or withoutOverlapping tasks get permanently locked. Adding a dead man's switch callback to each scheduled command detects every failure mode without modifying your business logic.

A few months ago I was contracting for a Laravel shop that processed subscription invoices through a scheduled Artisan command. It ran every hour via schedule:run. One day the queue worker on their production server got OOM-killed. The scheduler kept running, kept dispatching jobs to the queue, but nothing was processing them. The queue backed up silently for two weeks. They found out when customers started asking why they hadn't been billed -and then got hit with two weeks of charges at once.

Laravel's task scheduler is elegant, but it has a blind spot: it doesn't know if your tasks actually completed. It fires and forgets. Here's how to close that gap.

How Does Laravel's Task Scheduler Actually Work?

The setup looks simple. You add a single cron entry that runs php artisan schedule:run every minute:

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

Then you define your scheduled tasks in app/Console/Kernel.php (or in Laravel 11+, in routes/console.php):

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('invoices:process')->hourly();
    $schedule->command('reports:generate')->dailyAt('02:00');
    $schedule->command('cleanup:expired-sessions')->daily();
}

Every minute, schedule:run checks which tasks are due and executes them. Simple enough. The problems start when things go wrong between "dispatched" and "completed."

What Are the Common Laravel Scheduling Failure Modes?

I've catalogued these from my own projects and from debugging other teams' setups:

  • The cron entry itself is missing. Deployments overwrite the crontab, someone rebuilds the server and forgets to add it back. schedule:run never fires. Nothing errors because nothing runs.
  • Artisan command throws an exception. The scheduler catches it, logs it (maybe), and moves on to the next task. The schedule keeps running on time. Your command just silently fails every hour.
  • Queue worker is dead. If your scheduled command dispatches jobs to the queue (which is common for heavy tasks), the command itself "succeeds" -it dispatched the job. But nothing processes it.
  • Overlapping tasks. A daily report that usually takes 5 minutes suddenly takes 3 hours because the dataset grew. The next day's run gets skipped by withoutOverlapping(). Then the next. Your report is now days stale.
  • Memory limits. PHP hits the memory limit mid-task. The process dies, Laravel logs a fatal error that gets rotated away, the scheduler runs again next hour like nothing happened.

How Do You Add Dead Man's Switch Monitoring to Laravel?

The cleanest approach is to use Laravel's built-in after callback to ping an external monitoring endpoint after each critical task completes successfully:

use Illuminate\Support\Facades\Http;

protected function schedule(Schedule $schedule): void
{
    $schedule->command('invoices:process')
        ->hourly()
        ->after(function () {
            Http::retry(3, 100)->timeout(10)
                ->get('https://deadping.io/api/ping/YOUR_INVOICE_TOKEN');
        });

    $schedule->command('reports:generate')
        ->dailyAt('02:00')
        ->after(function () {
            Http::retry(3, 100)->timeout(10)
                ->get('https://deadping.io/api/ping/YOUR_REPORT_TOKEN');
        });
}

The after callback runs regardless of whether the command succeeded or failed. If you only want to ping on success, use onSuccess instead:

$schedule->command('invoices:process')
    ->hourly()
    ->onSuccess(function () {
        Http::retry(3, 100)->timeout(10)
            ->get('https://deadping.io/api/ping/YOUR_INVOICE_TOKEN');
    })
    ->onFailure(function () {
        Log::error('invoices:process failed - DeadPing will alert on missed ping');
    });

I prefer onSuccess because it means the ping only fires when the command actually completed without throwing. If the command throws, no ping, and your dead man's switch triggers after the grace period.

What About Queued Commands?

This is where it gets tricky. If your scheduled command dispatches jobs to the queue, the command itself finishes instantly -it just pushed messages. The actual work happens asynchronously in the queue worker. Pinging after the command completes only proves the jobs were dispatched, not processed.

For queued work, put the ping at the end of the job itself:

// app/Jobs/ProcessInvoices.php
class ProcessInvoices implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle(): void
    {
        $invoices = Invoice::where('status', 'pending')->get();

        foreach ($invoices as $invoice) {
            $invoice->process();
        }

        // Only ping after all invoices are actually processed
        Http::retry(3, 100)->timeout(10)
            ->get('https://deadping.io/api/ping/YOUR_INVOICE_TOKEN');
    }
}

Now the ping proves end-to-end completion: the scheduler ran, the job was dispatched, the queue worker picked it up, and the processing finished without exceptions.

How Do You Handle withoutOverlapping Tasks?

If you use withoutOverlapping(), be aware that skipped runs don't fire any callbacks. The scheduler checks the mutex, sees the previous run is still going, and silently skips. This is fine if it happens occasionally, but if your task is permanently stuck (dead worker holding a lock), every subsequent run gets skipped forever.

Set your dead man's switch grace period to accommodate the longest reasonable run time. If your daily report usually takes 10 minutes but could take up to an hour on month-end, set the grace period to 2 hours. If the ping doesn't arrive within 26 hours, something is genuinely wrong -either the task is stuck or it's being skipped.

A Reusable Trait for All Your Commands

If you have many scheduled commands, a trait keeps things DRY:

// app/Concerns/PingsDeadPing.php
namespace App\Concerns;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

trait PingsDeadPing
{
    protected function pingMonitor(string $token): void
    {
        try {
            Http::retry(3, 100)->timeout(10)
                ->get("https://deadping.io/api/ping/{$token}");
        } catch (\Throwable $e) {
            Log::warning("DeadPing ping failed: {$e->getMessage()}");
            // Don't throw - monitoring failure shouldn't break the task
        }
    }
}

Then in any command or job:

class ProcessInvoices extends Command
{
    use PingsDeadPing;

    public function handle(): int
    {
        // ... your task logic ...

        $this->pingMonitor('YOUR_INVOICE_TOKEN');
        return Command::SUCCESS;
    }
}

I use this pattern across every Laravel project now. Setting up a monitor in DeadPing takes about two minutes -create the monitor, set the expected interval, copy the token into your command. The first time it catches a dead queue worker at 3am instead of letting it fester until Monday, you'll wonder how you ever ran scheduled tasks without it. The getting started guide walks through creating your first monitor, and the API reference covers automating monitor creation for multiple tasks.

Start monitoring in 60 seconds

Free forever for up to 20 monitors. No credit card required.

Get Started Free