error("Cannot create backup directory: {$dir}");
return self::FAILURE;
}
$stamp = now()->format('Ymd-His');
$failed = false;
if (! $this->option('media-only')) {
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
}
if ($this->option('media') || $this->option('media-only')) {
$failed = ! $this->mirrorMedia() || $failed;
} else {
$this->line(' – media not included (pass --media to mirror it)');
}
$this->shipOffServer();
$this->prune($dir);
if ($failed) {
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
$this->error('Backup FAILED — see the errors above.');
return self::FAILURE;
}
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
$this->newLine();
$this->info('Backup complete.');
return self::SUCCESS;
}
/* ---------------- Database ---------------- */
private function backupDatabase(string $dir, string $stamp): bool
{
$connection = config('database.default');
$driver = config("database.connections.{$connection}.driver");
return match ($driver) {
'sqlite' => $this->backupSqlite($dir, $stamp),
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
};
}
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
private function backupSqlite(string $dir, string $stamp): bool
{
$target = $dir."/db-{$stamp}.sqlite";
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
// something cryptic, say so plainly — a backup that quietly produces
// nothing is the worst possible outcome.
if (DB::transactionLevel() > 0) {
$this->error('Cannot snapshot the database inside an open transaction.');
return false;
}
try {
// Harmless when the database is not in WAL mode.
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
} catch (\Throwable $e) {
$this->error('Database snapshot failed: '.$e->getMessage());
return false;
}
if (! $this->verifySqlite($target)) {
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' ✓ database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/**
* Read the snapshot back. A file that exists but cannot be opened, or that
* holds no migrations and no videos, is not a backup worth trusting.
*/
private function verifySqlite(string $path): bool
{
try {
$pdo = new PDO('sqlite:'.$path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
$this->error("Snapshot failed integrity check: {$path}");
return false;
}
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
if ($migrations < 1) {
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
return false;
}
// Read a real application table too. `migrations` alone would still
// be present in a snapshot taken of an empty database.
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
$this->line(" ✓ verified integrity ok · {$migrations} migrations · {$videos} videos");
} catch (\Throwable $e) {
$this->error('Snapshot could not be verified: '.$e->getMessage());
return false;
}
return true;
}
private function backupMysql(string $dir, string $stamp, string $connection): bool
{
$target = $dir."/db-{$stamp}.sql.gz";
$c = config("database.connections.{$connection}");
$command = sprintf(
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
escapeshellarg((string) $c['host']),
escapeshellarg((string) $c['port']),
escapeshellarg((string) $c['username']),
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
escapeshellarg((string) $c['database']),
escapeshellarg($target),
);
exec($command, $out, $status);
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
$this->error('mysqldump failed (exit '.$status.').');
return false;
}
// Verify the dump is readable and non-trivial rather than trusting the
// exit code — a gzip of an error message also exits 0.
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
if ($verify !== 0) {
$this->error('Dump failed its gzip integrity check — discarding.');
@unlink($target);
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' ✓ database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/* ---------------- Media ---------------- */
/**
* Mirror the media library with rsync.
*
* Uploaded video cannot be regenerated from anything — losing it is the
* one unrecoverable outcome on this platform. rsync is used rather than an
* archive because the library is large and almost entirely immutable, so
* each run copies only what is new.
*/
private function mirrorMedia(): bool
{
$destination = (string) config('backup.media_destination');
if ($destination === '') {
$this->newLine();
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
return false;
}
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
$this->error("Media destination is not reachable: {$destination}");
return false;
}
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
if (! $paths) {
$this->line(' – media nothing to mirror');
return true;
}
// --delete is opt-in and off by default: a mirror that deletes is one
// wrong path away from erasing the copy it exists to protect.
$flags = '-a --partial --info=stats2';
if (config('backup.media_delete')) {
$flags .= ' --delete';
}
$ok = true;
foreach ($paths as $path) {
// Trailing slash on the source, basename on the destination, so
// each folder lands in its own directory rather than merging.
$command = sprintf(
'rsync %s %s %s 2>&1',
$flags,
escapeshellarg(rtrim($path, '/').'/'),
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
);
exec($command, $out, $status);
if ($status !== 0) {
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
$ok = false;
continue;
}
$this->line(sprintf(' ✓ media %s → %s', basename($path), $destination));
}
return $ok;
}
/* ---------------- Off-server ---------------- */
private function shipOffServer(): void
{
$disk = config('backup.disk');
if (! $disk) {
$this->newLine();
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
return;
}
foreach ($this->artifacts as $artifact) {
try {
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
$this->line(" ✓ off-server {$disk}:backups/".basename($artifact));
if (! $this->option('keep-local')) {
@unlink($artifact);
}
} catch (\Throwable $e) {
// Never delete the local copy when the upload failed — it is
// the only copy left.
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
}
}
}
/* ---------------- Rotation ---------------- */
/**
* Delete artifacts past the retention window, but always keep the newest
* few — a box that sat idle for a month must still have something to
* restore from.
*/
private function prune(string $dir): void
{
$days = (int) config('backup.retain_days', 14);
if ($days <= 0) {
return;
}
$keep = max(0, (int) config('backup.keep_minimum', 3));
$cutoff = now()->subDays($days)->getTimestamp();
$removed = 0;
$files = glob($dir.'/db-*') ?: [];
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
foreach (array_slice($files, $keep) as $file) {
if (filemtime($file) < $cutoff && @unlink($file)) {
$removed++;
}
}
if ($removed) {
$this->line(" ✓ pruned {$removed} artifact(s) older than {$days} days");
}
}
private function humanSize(string $path): string
{
$bytes = (int) @filesize($path);
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 1).' '.$units[$i];
}
}