439ef605a1
Resend mail transport:
- composer require resend/resend-laravel (v1.4)
- Laravel 11 ships the 'resend' mailer config in config/mail.php + services
- To switch to production email: set MAIL_MAILER=resend + RESEND_API_KEY,
register the domain at resend.com/domains, and add the TXT + DKIM CNAME
records in Cloudflare. .env.example documents the required steps.
Backblaze B2 offsite backup:
- New filesystems 'b2' disk (S3-compatible, env: B2_KEY/SECRET/BUCKET/REGION/ENDPOINT)
- BackupAllTenantsCommand: after writing each tenant's zip to local disk, it
uploads the same file to the b2 disk under {YYYY-MM-DD}/{slug}.zip — only
when both B2_KEY and B2_BUCKET are set, so unconfigured installs are no-op
- Without offsite, backups live on the same VPS as production: a single
hardware failure loses everything. B2 + Resend together make the install
genuinely production-ready (people get email + offsite backup).
Tests (2 new):
- backup uploads to b2 (fake disk) when configured
- backup skips offsite when env vars not present
Full suite: 140 passed. Force-rebuild deploy required so composer install
picks up resend/resend-php.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.6 KiB
PHP
106 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Central\Company;
|
|
use App\Services\TenantBackupService;
|
|
use App\Tenancy\TenantManager;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class BackupAllTenantsCommand extends Command
|
|
{
|
|
protected $signature = 'backup:tenants
|
|
{--keep=14 : How many daily backups to retain per tenant}
|
|
{--slug= : Backup only one tenant by slug}';
|
|
|
|
protected $description = 'Export ZIP backup for every active tenant. Stored in storage/app/backups/{date}/';
|
|
|
|
public function handle(TenantBackupService $service): int
|
|
{
|
|
$query = Company::query()->where('status', '!=', 'archived');
|
|
if ($slug = $this->option('slug')) {
|
|
$query->where('slug', $slug);
|
|
}
|
|
$companies = $query->get();
|
|
|
|
if ($companies->isEmpty()) {
|
|
$this->warn('No tenants to back up.');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$date = date('Y-m-d');
|
|
$base = storage_path("app/backups/{$date}");
|
|
if (! is_dir($base)) @mkdir($base, 0775, true);
|
|
|
|
$manager = app(TenantManager::class);
|
|
$ok = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($companies as $company) {
|
|
try {
|
|
$manager->setCurrent($company);
|
|
app(\Spatie\Permission\PermissionRegistrar::class)
|
|
->setPermissionsTeamId($company->id);
|
|
|
|
$tmp = $service->export($company);
|
|
$dest = "{$base}/{$company->slug}.zip";
|
|
@rename($tmp, $dest);
|
|
|
|
$size = round(filesize($dest) / 1024, 1);
|
|
$this->info("✓ {$company->slug} → {$size}KB");
|
|
|
|
// Offsite copy to B2 (if configured) — disk lazily resolved.
|
|
$this->uploadOffsite($dest, "{$date}/{$company->slug}.zip");
|
|
$ok++;
|
|
} catch (\Throwable $e) {
|
|
$this->error("✗ {$company->slug}: {$e->getMessage()}");
|
|
$failed++;
|
|
} finally {
|
|
$manager->setCurrent(null);
|
|
}
|
|
}
|
|
|
|
$this->cleanupOld((int) $this->option('keep'));
|
|
|
|
$this->newLine();
|
|
$this->info("Backup completed: {$ok} ok, {$failed} failed.");
|
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
/** Upload one backup zip to the offsite B2 disk if env is configured. */
|
|
private function uploadOffsite(string $localPath, string $remoteKey): void
|
|
{
|
|
if (! env('B2_KEY') || ! env('B2_BUCKET')) return;
|
|
try {
|
|
$stream = fopen($localPath, 'rb');
|
|
\Illuminate\Support\Facades\Storage::disk('b2')->put($remoteKey, $stream);
|
|
if (is_resource($stream)) fclose($stream);
|
|
$this->line(" ↑ offsite: {$remoteKey}");
|
|
} catch (\Throwable $e) {
|
|
$this->warn(" ✗ offsite upload failed: " . substr($e->getMessage(), 0, 120));
|
|
}
|
|
}
|
|
|
|
private function cleanupOld(int $keep): void
|
|
{
|
|
$backupsDir = storage_path('app/backups');
|
|
if (! is_dir($backupsDir)) return;
|
|
|
|
$dates = collect(scandir($backupsDir))
|
|
->filter(fn ($d) => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d))
|
|
->sortDesc()
|
|
->values();
|
|
|
|
$toDelete = $dates->slice($keep);
|
|
foreach ($toDelete as $dateDir) {
|
|
$abs = "{$backupsDir}/{$dateDir}";
|
|
if (is_dir($abs)) {
|
|
array_map('unlink', glob("{$abs}/*"));
|
|
@rmdir($abs);
|
|
$this->line("Removed old backup: {$dateDir}");
|
|
}
|
|
}
|
|
}
|
|
}
|