Your Backup Ran.
But Would It Actually Restore?
laravel-backup confirms a file was uploaded. It does not confirm your data is recoverable. Here is how to close that gap with app-aware verification.
In This Guide
1. What laravel-backup Actually Checks
A few years ago I watched a team discover — during an actual incident — that six months of nightly backups had been quietly failing to include the uploads directory. The Slack notification every morning said "Backup completed successfully." The backup file existed on S3. It just didn't have half the data they needed.
Spatie's laravel-backup is excellent. It handles compression, encryption, multi-disk uploads, and cleanup. The backup:monitor command checks two things:
Backup age
Was a backup created recently enough? Catches cases where the backup job stopped running entirely.
Backup size
Is the file above a minimum threshold? A 2 KB zip where you expect 200 MB is a red flag.
Both checks operate on the backup file. Neither can tell you:
Whether the archive is actually extractable
Whether the SQL dump inside is valid and complete
Whether the restore process completes without errors
Whether your data is semantically intact — right row counts, recent records, no silent truncation
The gap between "file exists" and "data recoverable"
Size checks catch catastrophic failures but miss a lot: a corrupt archive that passes a stat check, a dump that errors mid-stream and produces a partial file, a misconfigured include path that silently omits entire tables. The gap between "backup file present" and "data recoverable" is where disasters live — and it tends to surface at the worst possible moment.
2. The App-Aware Verification Approach
The idea is straightforward: periodically, actually restore the backup — not to production, but to a shadow database — and then run assertions against the restored data.
Identify the most recent backup
Locate the latest backup file on each configured destination disk.
Download and extract
Pull the archive to a temporary local path and extract it. A failure here confirms the archive is corrupt.
Restore to a shadow database
Apply the SQL dump to a dedicated shadow database connection. A failure here confirms the dump is invalid or incomplete.
Run assertions
Check row counts, recent data, business-logic invariants — whatever confirms your data is coherent for this specific application.
Clean up and alert
Drop the shadow database, remove temp files, report pass or fail. Any failure should alert immediately.
The shadow database
A shadow database is a dedicated MySQL instance (or connection to a separate RDS instance) used only for restore tests. It doesn't need to be production-scale — it only holds data long enough to run your assertions, then gets dropped and recreated for the next run. A small local MySQL instance works fine for most teams.
3. The Verification Job
Add a shadow connection to config/database.php pointing at your dedicated restore instance, then create the job:
class VerifyBackupIntegrityJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600;
public int $tries = 1; // failures need human eyes, not retries
public function __construct(
private readonly string $disk,
private readonly string $backupPath,
) {}
public function handle(): void
{
$tempDir = storage_path('app/backup-verification/' . uniqid());
try {
$this->extractBackup($tempDir);
$this->restoreToShadow($tempDir);
$this->runAssertions();
Notification::route('slack', config('backup-verify.slack_webhook'))
->notify(new BackupVerificationPassed($this->disk, $this->backupPath));
} catch (Exception $e) {
Log::error('Backup verification FAILED', [
'disk' => $this->disk,
'path' => $this->backupPath,
'error' => $e->getMessage(),
]);
Notification::route('slack', config('backup-verify.slack_webhook'))
->notify(new BackupVerificationFailed($this->disk, $this->backupPath, $e->getMessage()));
throw $e; // surfaces as failed in Horizon
} finally {
$this->cleanup($tempDir);
}
}
private function extractBackup(string $tempDir): void
{
mkdir($tempDir, 0755, true);
$localZip = $tempDir . '/backup.zip';
$contents = Storage::disk($this->disk)->get($this->backupPath);
if ($contents === null) {
throw new Exception("Could not download backup from [{$this->disk}]:[{$this->backupPath}]");
}
file_put_contents($localZip, $contents);
$zip = new ZipArchive();
$result = $zip->open($localZip);
if ($result !== true) {
throw new Exception("Archive failed to open — ZipArchive error: {$result}");
}
$zip->extractTo($tempDir . '/extracted');
$zip->close();
}
private function restoreToShadow(string $tempDir): void
{
$dumps = glob($tempDir . '/extracted/*/db-dumps/*.sql');
if (empty($dumps)) {
throw new Exception('No SQL dump found in backup archive');
}
$conn = config('database.connections.shadow');
DB::statement("DROP DATABASE IF EXISTS `{$conn['database']}`");
DB::statement("CREATE DATABASE `{$conn['database']}`");
$command = sprintf(
'mysql -h%s -u%s -p%s %s < %s 2>&1',
escapeshellarg($conn['host']),
escapeshellarg($conn['username']),
escapeshellarg($conn['password']),
escapeshellarg($conn['database']),
escapeshellarg($dumps[0])
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
throw new Exception('mysql restore failed: ' . implode("\n", $output));
}
}
private function runAssertions(): void
{
foreach (config('backup-verify.assertions', []) as $name => $assertion) {
$assertion(DB::connection('shadow'), $name);
}
}
private function cleanup(string $tempDir): void
{
if (is_dir($tempDir)) {
exec('rm -rf ' . escapeshellarg($tempDir));
}
$db = config('database.connections.shadow.database');
DB::statement("DROP DATABASE IF EXISTS `{$db}`");
DB::statement("CREATE DATABASE `{$db}`");
}
}
Why $tries = 1? A backup verification failure is not a transient error. You want it loud and visible in your queue monitor, not silently retried. A failed job in Horizon is the signal that something needs human attention.
4. Defining Assertions
This is where "app-aware" actually means something. Create config/backup-verify.php:
return [
'slack_webhook' => env('BACKUP_VERIFY_SLACK_WEBHOOK'),
'assertions' => [
'users table is non-empty' => function (Connection $db, string $name): void {
if ($db->table('users')->count() < 1) {
throw new Exception("[{$name}] Expected users, found 0");
}
},
'users exceed baseline' => function (Connection $db, string $name): void {
$count = $db->table('users')->count();
if ($count < 500) { // adjust to your production floor
throw new Exception("[{$name}] users count {$count} below baseline 500");
}
},
'orders placed in last 7 days' => function (Connection $db, string $name): void {
$count = $db->table('orders')
->where('created_at', '>=', now()->subDays(7))
->count();
if ($count < 10) {
throw new Exception("[{$name}] Only {$count} orders in 7 days — data may be stale");
}
},
'no orphaned order items' => function (Connection $db, string $name): void {
$orphans = $db->table('order_items')
->leftJoin('orders', 'order_items.order_id', '=', 'orders.id')
->whereNull('orders.id')
->count();
if ($orphans > 0) {
throw new Exception("[{$name}] {$orphans} orphaned order_items — backup may be partial");
}
},
'critical config keys present' => function (Connection $db, string $name): void {
foreach (['app.version', 'features.checkout'] as $key) {
if (! $db->table('settings')->where('key', $key)->exists()) {
throw new Exception("[{$name}] Missing settings key: {$key}");
}
}
},
],
];
These assertions encode your application's invariants
A generic backup checker cannot know that you should have recent orders, or that orphaned order items indicate a partial dump. You can. Keep assertions fast — aim for under a second each. They run against a restored shadow database, so complex queries are fine, but a 30-second assertion balloons the job.
Start conservative, tighten over time
Begin with the non-empty check and a loose row-count baseline. Once you know your production minimums, tighten the baselines. Add the orphan check and recent-data assertions once you're confident the basic restore is working reliably.
5. Multi-Destination Coverage
Multi-destination backups are only redundant if you verify each destination independently. A corrupted S3 upload and a healthy Glacier copy are two different operational states. Create an artisan command that dispatches a verification job per destination:
class VerifyBackups extends Command
{
protected $signature = 'backup:verify-integrity';
protected $description = 'Dispatch backup verification jobs for all destinations';
public function handle(): int
{
foreach (config('backup-verify.destinations') as $disk => $pathOverride) {
$path = $pathOverride ?? $this->resolveLatestBackup($disk);
if ($path === null) {
$this->error("No backup found on disk [{$disk}]");
continue;
}
VerifyBackupIntegrityJob::dispatch($disk, $path)
->onQueue('backup-verification');
$this->info("Dispatched verification for [{$disk}]: {$path}");
}
return self::SUCCESS;
}
private function resolveLatestBackup(string $disk): ?string
{
$files = Storage::disk($disk)->files(config('app.name') . '/');
if (empty($files)) {
return null;
}
sort($files); // laravel-backup names with timestamps — latest is alphabetically last
return end($files);
}
}
Cold storage note
Glacier and similar cold-storage tiers have retrieval delays (minutes to hours depending on tier). For cold destinations, consider a two-phase approach: initiate the retrieval in one job, verify once the data is staged. For most teams, verifying S3 primary daily and cold storage weekly is a practical trade-off that catches the significant cases without burning retrieval costs.
6. Scheduling and Alerting
Schedule verification to run after the backup has had time to complete:
// routes/console.php (Laravel 11+)
Schedule::command('backup:run')->dailyAt('02:00');
Schedule::command('backup:verify-integrity')->dailyAt('04:00');
The two-hour gap is intentional. If your backup regularly takes more than two hours, adjust accordingly — the verification job will fail cleanly with a "no backup found" error rather than silently verifying yesterday's backup.
Make failure notifications actionable
The alert should include: which disk failed, which assertion failed (or that the archive was corrupt), a timestamp, and a direct link to your Horizon dashboard. A verification failure at 4am should page someone. A backup that cannot be verified is, operationally, the same as no backup.
Test the test
After deploying, deliberately break something — point the shadow connection at the wrong host, or corrupt a local test archive — and confirm the failure notification actually fires. It sounds obvious. Do it anyway. A verification pipeline that fails silently gives you a false sense of security.
7. Frequently Asked Questions
Does laravel-backup verify that a backup is restorable?
No. The backup:monitor command checks backup age and file size. It does not extract the archive, restore the database dump, or validate that your data is intact. A corrupt archive or partial dump that passes both checks will not be caught until you attempt a restore.
What is app-aware backup verification?
It goes beyond confirming a file exists. It actually restores the backup to a shadow database and runs assertions specific to your application — row counts, presence of recent data, business-logic invariants. Generic backup monitors cannot do this because they do not know what your data is supposed to look like. You do.
What is a shadow database?
A dedicated database connection used only for restore tests. It receives the restored dump, has assertions run against it, then gets dropped and recreated for the next verification run. It does not need to be production-scale — a small local MySQL instance holds data long enough to run your assertions.
How often should I run backup verification?
At minimum daily, a couple of hours after your backup job runs. For multi-destination backups, run a verification job per destination — a healthy S3 upload and a corrupt Glacier copy are different states that need separate alerts. Test the alerting path at least once by deliberately breaking something before you rely on it.
Will laravel-backup v10 add restore verification?
Laravel Backup v10 adds more monitoring capabilities, but app-aware data-integrity verification is inherently application-specific — it requires knowing what your data is supposed to look like. That is something a generic package cannot provide. The assertions layer here (row counts, recent data, business invariants) is the part you will always need to write yourself.
Need a Backup System Built or Audited?
I've been building and operating production systems for over 18 years.
Whether you need a backup system designed from scratch, an existing one audited for correctness, or a reliable restore path — this work is often part of a broader small business automation project. Most of these conversations are short.