← All posts

8 September 2026

Laravel backup monitoring: a practical guide

Most Laravel apps that back up their database do it with spatie/laravel-backup, scheduled nightly, with a failure notification going to mail or Slack. That is a backup job. It is not backup monitoring, and the difference is the gap your data falls through.

Monitoring answers one question: if I needed to restore right now, could I, and from when? This guide is about answering it continuously, starting with what Spatie's package already gives you and working outward to the parts nothing inside your app can do.

What spatie/laravel-backup gives you out of the box

More than most people configure. Three things matter:

Notifications. The package fires an event for every outcome — BackupWasSuccessful, BackupHasFailed, CleanupHasFailed, UnhealthyBackupWasFound and so on — and routes each to the channels you list in config/backup.php:

'notifications' => [
    BackupHasFailedNotification::class         => ['mail', 'slack'],
    UnhealthyBackupWasFoundNotification::class => ['mail', 'slack'],
    CleanupHasFailedNotification::class        => ['mail'],
    BackupWasSuccessfulNotification::class     => [],
    HealthyBackupWasFoundNotification::class   => [],
    CleanupWasSuccessfulNotification::class    => [],
],

Turn the success notifications off. A nightly "backup succeeded" email trains everyone to ignore the channel, and the one that matters gets filed with the rest.

Health checks. php artisan backup:monitor inspects each destination and fires UnhealthyBackupWasFound if a check fails. Two checks ship by default, and they are the ones you want:

'monitor_backups' => [
    [
        'name'   => env('APP_NAME'),
        'disks'  => ['s3'],
        'health_checks' => [
            MaximumAgeInDays::class          => 1,
            MaximumStorageInMegabytes::class => 5000,
        ],
    ],
],

MaximumAgeInDays is the important one. It doesn't ask whether the last job succeeded; it asks whether the newest file on the disk is recent enough. That catches a backup that has stopped being written, whatever the reason.

Cleanup. backup:clean applies a retention strategy so the bucket doesn't fill and a storage-quota failure doesn't take the backups down with it.

Schedule all three:

Schedule::command('backup:clean')->daily()->at('01:00');
Schedule::command('backup:run')->daily()->at('01:30');
Schedule::command('backup:monitor')->daily()->at('03:00');

If you have done this, you are ahead of most production Laravel apps. Now the honest part.

Where the built-in monitoring stops

Everything above runs inside the application it is protecting, and that is the limit.

  • backup:monitor runs on the same scheduler as backup:run. If cron stops calling schedule:run — a rebuilt server, a rewritten crontab, a container that came up without its scheduler — the backup stops and so does the check that would notice. Silence.
  • Notifications share the app's mailer and queue. An expired SMTP credential or a dead queue worker loses the alert about the broken backup along with everything else.
  • There is no history. The package knows about the latest file. It cannot show you that backups have been shrinking for a week, or that a destination last succeeded on the 3rd and has failed every night since.
  • A file that exists passes every check. A dump that mysqldump truncated mid-stream is recent, non-empty and unreachable by any health check. It fails at restore time.

None of this is a criticism of the package. It is the boundary of what can be known from inside the process. Backup monitoring means adding what sits outside it.

The four layers

  1. History — a durable record of every run, with size and duration, so a trend is visible before a restore is needed.
  2. A dead-man's switch — something that expects a backup to happen and alerts when it doesn't, rather than something that reports when it fails.
  3. An alert path that does not depend on the app — so the alert survives what killed the backup.
  4. A restore test — the only check that proves a backup is a backup.

Here is how to build each with the least machinery.

Step 1: an external heartbeat

Sign up for a cron-monitoring service — healthchecks.io, Cronitor, or similar — and create a check that expects a ping every 24 hours with a grace period. Then let Laravel's scheduler ping it, but only on success:

Schedule::command('backup:run')
    ->daily()->at('01:30')
    ->withoutOverlapping()
    ->onOneServer()
    ->pingOnSuccess(env('BACKUP_HEARTBEAT_URL'))
    ->pingOnFailure(env('BACKUP_HEARTBEAT_URL').'/fail');

This is layers two and three in six lines. The service is outside your app. It alerts on the absence of a ping, so a dead scheduler, a crashed container and a failed run all look the same from its side: no heartbeat, raise the alarm. Its notifications go out over its own channels, not your mailer.

Two details. Use pingOnSuccess, not thenPing — the latter pings whether the command succeeded or not, which turns your dead-man's switch back into a "job ran" indicator. And give the heartbeat URL its own environment variable per environment, or staging will keep production's check green.

Step 2: keep a history

Record every run somewhere you will look. The events are already firing; you need a listener that writes a row per event with the outcome, the destination, the size and the time.

Our free package Backup Monitor does exactly this into a backup_runs table, and Filament Backup Monitor shows it as a run-history table with a last-backup-per-destination view. If you don't use Filament, a listener on BackupWasSuccessful and BackupHasFailed plus a query is an afternoon's work.

Either way, the number to watch is size. A backup that is suddenly a tenth of yesterday's is a truncated dump, a dropped table, or a --only-db flag someone added, and it will pass every other check.

Step 3: alert on the missing run, not just the failed one

The heartbeat covers "no backup at all". The finer-grained version is per destination: the S3 copy is fine, the second region's copy quietly stopped three weeks ago.

Spatie's MaximumAgeInDays check does this per disk, so make sure every destination is listed in monitor_backups — the default config lists only local. If you want the check to run from somewhere other than the scheduler it is checking, pair it with the heartbeat: schedule backup:monitor with its own pingOnSuccess, so a monitor that stops running is itself caught.

Backup Monitor Pro adds the version of this we wanted for our own sites: a per-destination expected cadence and grace period, alerts that fire once and stay quiet while the destination remains overdue, escalation to more channels the longer it stays down, a recovery notice, and the heartbeat ping built in.

Step 4: restore, on a schedule

Everything above proves that a file exists and is recent. Only a restore proves it is a backup.

Do it on a schedule, scripted, into a throwaway database. The shape is the same everywhere:

# fetch the newest backup and unpack it
aws s3 cp "s3://my-bucket/$(aws s3 ls s3://my-bucket/ | sort | tail -n1 | awk '{print $4}')" latest.zip
unzip -o latest.zip -d restore/

# restore into a scratch database
createdb restore_check
psql restore_check < restore/db-dumps/postgresql-*.sql

# prove it is usable, not just loadable
psql restore_check -c "select count(*) from users;"
php artisan migrate:status --database=restore_check

dropdb restore_check

Ping a second heartbeat when this succeeds and you have a monitor for the thing that actually matters. Weekly is enough for most apps. If a failed restore would end the business, do it daily and keep the output.

Step 5: if you run more than a few sites

Past four or five apps, per-site Slack alerts stop working: the noise is spread across channels and nobody owns the overview. What you want is one screen that answers "which of my sites has not backed up successfully in the last day?"

That is what the Collector is for. Each site's Pro install pushes a health snapshot to a collector you host, and a Filament dashboard shows every site's last successful backup, overdue destinations, and installs that have gone quiet — which is its own signal, because a site that stops reporting has usually stopped backing up too.

The checklist

  • Success notifications off, failure and unhealthy notifications to a channel someone reads.
  • backup:run, backup:clean and backup:monitor scheduled, with every destination in monitor_backups.
  • An external heartbeat pinged only on success, with a grace period, per environment.
  • A run history with sizes, and someone who looks at it.
  • A scheduled restore into a scratch database that proves the dump is usable.
  • One overview if you run many sites.

A backup job tells you it ran. Monitoring tells you that you could restore, from when, and that you would hear about it if that stopped being true.


We build the middle layers of this. Backup Monitor is free and records every spatie/laravel-backup run. Backup Monitor Pro adds missed-backup detection, multi-channel alerting and the built-in heartbeat. The Collector puts every site on one dashboard. The restore test is yours to write; the post on silent backup failures explains why it is the one you cannot skip.