Laravel’s scheduler has one quiet weakness: if your server is down when a task is due, that run is simply gone. A midnight report that should have run during a deploy or an outage never happens, and nothing in the framework goes back for it. Schedule Catchup, a new package by Arowosaye Remilekun featured on Laravel News on 11 November 2026, fills that gap. This tutorial walks through installing it, tagging the tasks that matter, writing replay-safe commands and checking what will run before it runs.

What problem it solves

Laravel’s schedule:run only looks at the current minute. If cron or your scheduler container was not running at 00:00, a task with dailyAt('00:00') just doesn’t happen that day. For a cache warm-up that’s fine. For daily invoices, subscription renewals or compliance reports, it is a real bug.

Schedule Catchup records scheduler activity in the database. When the scheduler comes back, it works out which cron slots were missed for the tasks you opted in, and replays them oldest first, with bounded retries.

Requirements and installation

The package needs PHP 8.2 or newer and supports Laravel 11, 12 and 13. Install it and run its migrations:

composer require cyberwizard/schedule-catchup
php artisan migrate

The service provider is auto-discovered. According to the project README, it registers schedule-catchup:run and schedule-catchup:heartbeat on a five-minute schedule with withoutOverlapping() and onOneServer(), and schedules schedule-catchup:prune daily at 04:10. You don’t need to add these to routes/console.php yourself.

To change defaults, publish the config:

php artisan vendor:publish --tag=schedule-catchup-config

Step 1: Tag only the tasks that need recovery

Nothing is replayed unless you opt in with replayOnCatchup():

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:daily')
    ->dailyAt('00:00')
    ->withoutOverlapping()
    ->onOneServer()
    ->replayOnCatchup();

// A poller that runs every five minutes does NOT need catch-up:
Schedule::command('orders:sync')->everyFiveMinutes();

Good candidates are tasks where each slot produces something that must exist: one report per day, one billing run per month, one data export per hour. High-frequency pollers that always look for outstanding work gain nothing from replays, and tagging them just gives catch-up more work to skip.

Step 2: Make commands use the scheduled date, not now()

This is the part that is easy to get wrong. If Friday’s midnight report is replayed on Saturday morning and the command calculates “yesterday” from now(), you get Friday’s report labelled with the wrong period, or a duplicate of Saturday’s.

The package ships a ResolvesScheduledTime trait. Its scheduledFor() method returns the missed slot’s timestamp during a replay and the current time during a normal run:

namespace App\Console\Commands;

use App\Models\DailyReport as Report;
use Cyberwizard\ScheduleCatchup\Traits\ResolvesScheduledTime;
use Illuminate\Console\Command;

class DailyReport extends Command
{
    use ResolvesScheduledTime;

    protected $signature = 'reports:daily';

    public function handle(): int
    {
        $reportDate = $this->scheduledFor()->subDay()->toDateString();

        // Idempotent: one row per reporting date, safe to retry.
        Report::updateOrCreate(
            ['report_date' => $reportDate],
            ['payload' => $this->buildReport($reportDate)]
        );

        if ($this->isCatchupReplay()) {
            $this->warn("Replayed missed run for {$reportDate}");
        }

        return self::SUCCESS;
    }

    private function buildReport(string $date): array
    {
        // Query orders, revenue, etc. for $date here.
        return ['date' => $date];
    }
}

Two things to notice. First, existing now() calls keep using the real clock, so you have to switch date-sensitive commands to scheduledFor() yourself. Second, updateOrCreate() keyed by the reporting date makes the command idempotent, which protects you from the retry case described below.

Step 3: Preview pending work with a dry run

Before letting it replay anything in production, see what it would do:

php artisan schedule-catchup:run --dry-run --only=reports:daily

The --only option matches a substring of the event summary. Drop --dry-run to execute that selection. Laravel News notes that a filtered run does not move the global checkpoint forward, so a targeted test won’t hide other missed work.

How recovery works under the hood

  • Heartbeat: every five minutes, schedule-catchup:heartbeat stores the last moment the scheduler is known to have been running.
  • Ledger: a listener on Laravel’s ScheduledTaskFinished event records each normal run against the cron slot it satisfied, so a restart can’t redo work that already happened.
  • Replay walk: schedule-catchup:run walks each tagged event’s cron expression from the checkpoint to now, removes slots already in the ledger and runs the rest oldest first.
  • First run: with no previous checkpoint, it records a baseline instead of guessing at unknown history.
  • Caps and retries: by default up to 50 occurrences per event per pass (max_occurrences), with the remainder draining on later passes, and failures retried up to max_attempts (default 3).
  • Conditions: replays respect when(), skip() and environments(). Events set to runInBackground() are replayed in the foreground so the real exit code is recorded.

Step 4: Get notified

The package dispatches ScheduleCatchupCompleted after successful replays and ScheduleCatchupFailed for new failures or exhausted attempts. Hook them up in a service provider:

use Cyberwizard\ScheduleCatchup\Events\ScheduleCatchupCompleted;
use Cyberwizard\ScheduleCatchup\Events\ScheduleCatchupFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

Event::listen(function (ScheduleCatchupCompleted $event) {
    Log::info('Schedule catch-up finished', ['replayed' => $event->replayed]);
});

Event::listen(function (ScheduleCatchupFailed $event) {
    Log::error('Schedule catch-up failure');
    // Send a Slack or mail notification to your team here.
});

Useful config options

The published config/schedule-catchup.php includes settings such as min_gap_minutes (ignore very short downtime, default 2), outage_after_minutes (record an outage after a gap this long, default 10), prune_after_days (default 90), cache_driver for the locks and table_prefix (default cyber_). Commands can also ask whether the server was down during a period with outageDuring(), which is handy for avoiding penalties for time nobody could work.

If you run the scheduler on several servers, configure a shared cache store so the scheduler mutexes and package locks work across machines.

Replay safety checklist

  • Use scheduledFor() anywhere a command derives a date range.
  • Make side effects idempotent. If a command sends an email and crashes before success is recorded, a retry could send it again.
  • Don’t tag frequent pollers.
  • Keep normal scheduler monitoring. Catch-up only works once the scheduler is running again.
  • Test with --dry-run after your first deploy.

Takeaway

Schedule Catchup turns “the server was down at midnight” from a silent data gap into a recorded, replayable event. Install it, tag the two or three tasks that truly must run every slot, switch those commands to scheduledFor(), and make them idempotent. That small investment pays off the first time a deploy or outage lands on a scheduled run.

Sources: Laravel News, cyberwizard-dev/schedule-catchup on GitHub.