Skip to main content
← All use cases

Detecting When Laravel backup:clean Fails to Run

Your S3 bill arrives. It’s 40% higher than last month. You dig into CloudWatch and find 180 backup archives sitting in your S3 bucket, dating back 6 months. backup:run has been executing flawlessly every night. backup:clean has been failing silently for months. You now have 180 daily backups worth of data sitting in cold storage, generating costs you didn’t budget for.

The problem with backup monitoring is that most tools watch for the backup to run. Nobody watches for the cleanup to work.

How backup:clean Works

The spatie/laravel-backup cleanup process runs separately from backup:run. It reads your backup.php retention configuration and deletes archives older than your specified thresholds:

// config/backup.php
'cleanup' => [
    'strategy' => \Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy::class,
    'default_strategy' => [
        'keep_all_backups_for_days' => 7,
        'keep_daily_backups_for_days' => 16,
        'keep_weekly_backups_for_months' => 1,
        'keep_monthly_backups_for_year' => 1,
        'keep_yearly_backups_for_years' => 1,
        'delete_oldest_backups_when_using_more_energy_than' => 5000,
    ],
],

When backup:clean runs, it evaluates all existing backups against this policy and deletes anything that falls outside the retention window. The critical detail: if backup:clean fails, exits with an error, or never runs, backup:run completes successfully anyway. They are independent processes.

Common Failure Modes

Cleanup disk unreachable. Your backup retention policy includes S3 as a destination. On a cold morning, your S3 IAM role temporary credentials expire and are not renewed by the instance metadata service. backup:run uploads successfully (it uses different credentials or cached tokens), but backup:clean tries to list S3 files, fails to connect, and exits with a connection error. The next night, backup:run succeeds, backup:clean fails again, and the cycle continues. You accumulate one backup per day.

Corrupted backup file blocks cleanup. One of your older backup archives is corrupt (incomplete upload from a previous S3 timeout). When backup:clean tries to evaluate whether to delete it, it reads the zip file header, detects corruption, and crashes. The entire cleanup process aborts. You now have the corrupt file plus all the files that weren’t evaluated for deletion. Spatie’s cleanup should handle this more gracefully, but in practice, corrupt files in the backup set can block cleanup of the entire set.

Retention policy misconfigured. A developer changes keep_all_backups_for_days from 7 to 70 as part of a compliance requirement, but doesn’t increase the cleanup frequency. With a daily backup frequency, this means 70 days of backups accumulate before the monthly retention policy begins pruning. Cleanup runs, but it’s keeping everything for 70 days, not 7. Storage costs climb while the job reports success.

Cleanup runs as wrong user. You schedule backup:clean via cron as root or a privileged user. The next server rebuild resets file permissions on the backup directory. The cleanup job can write to S3 (via IAM) but cannot list S3 objects to determine what to delete (requires s3:ListBucket permission, which is separate from s3:PutObject). The job fails with a permissions error that nobody sees because cron output is redirected.

Building Visibility Into backup:clean

The most reliable check: count the backup files and compare against your retention policy. If you expect 7 daily backups plus 4 weekly, you should have roughly 11 archives at any given time:

Schedule::call(function () {
    $disk = Storage::disk('s3');
    $zipFiles = collect($disk->files('laravel-backup'))
        ->filter(fn ($file) => str_ends_with($file, '.zip'));

    $count = $zipFiles->count();
    $oldestDate = $zipFiles
        ->map(fn ($file) => $disk->lastModified($file))
        ->min();

    $daysOld = now()->diffInDays(
        Carbon::createFromTimestamp($oldestDate)
    );

    Log::info("Backup cleanup check", [
        'file_count' => $count,
        'oldest_backup_days_ago' => $daysOld,
    ]);

    // Alert if more files than expected (cleanup not running)
    if ($count > 20) {
        // Crontinel heartbeat - this fires the alert
        Http::get(env('CRONTINEL_CLEAN_ENDPOINT'));
    }
})->dailyAt('06:00');

The logic here is inverted from backup:run monitoring. Instead of alerting on absence of success, you’re alerting on presence of too many files. If backup:clean is working, your file count stays bounded.

Detecting When backup:clean Fails

Combine the count check with a time-window check: if the oldest backup is more than keep_all_backups_for_days + 1 days old, cleanup either hasn’t run or isn’t deleting anything:

$maxRetentionDays = config('backup.cleanup.default_strategy.keep_all_backups_for_days', 7);

if ($daysOld > $maxRetentionDays + 2) {
    // oldest backup is older than our retention policy allows
    // either cleanup never ran, or it's failing silently
    Log::warning("Backup cleanup may have failed", [
        'oldest_backup_days_ago' => $daysOld,
        'expected_max_days' => $maxRetentionDays,
    ]);
}

Pair this with a direct check: after backup:clean runs, verify the file count decreased:

# Store backup count before and after cleanup
BEFORE=$(aws s3 ls s3://your-bucket/laravel-backup/ | grep -c '\.zip$')
php artisan backup:clean
AFTER=$(aws s3 ls s3://your-bucket/laravel-backup/ | grep -c '\.zip$')

if [ "$AFTER" -ge "$BEFORE" ]; then
    echo "Backup cleanup did not reduce file count - possible failure"
    # trigger alert
fi

Quick Setup with Crontinel

Schedule both backup and cleanup in the same monitoring context:

// config/backup.php - ensure cleanup is scheduled
Schedule::command('backup:run')->dailyAt('03:00')->withoutOverlapping()->onOneServer();
Schedule::command('backup:clean')->dailyAt('04:00')->withoutOverlapping()->onOneServer();

Crontinel’s Laravel package includes a dedicated backup-clean heartbeat:

Schedule::command('backup:clean')
    ->dailyAt('04:00')
    ->withoutOverlapping()
    ->onOneServer()
    ->thenPing(env('CRONTINEL_CLEAN_ENDPOINT'));

If backup:clean exits with an error, or if it never runs (your monitoring window closes without a ping), Crontinel fires an alert. The dual-check approach - counting files + monitoring the job itself - catches cleanup failures from every angle: corrupted files, permission errors, unreachable disks, and misconfigured retention policies.

Storage costs from accumulating backups are the silent version of data loss. You’re not losing data, but you’re paying for it every month.

See also

Start monitoring in minutes

Free for one app. No account needed to install and test locally.

composer require crontinel/laravel
php artisan crontinel:install
Get early access