# Backup & Restore

**Threat model:** shared hosting fails in three ways — accidental data
deletion by a user, host-side disk or account loss, and a bad deployment. The
strategy below covers all three, and every backup lives **off the cPanel
account** — a copy stored only alongside the thing it protects is not a
backup, it is a second copy of the same single point of failure.

See `docs/ARCHITECTURE.md` §20 for the reasoning; this document is the
runbook.

---

## 1. What is backed up

| Item | Contains | Frequency | Retention |
|---|---|---|---|
| MySQL database | all business data | daily, 02:00 | 7 daily · 4 weekly · 6 monthly |
| `storage/app/private` | import source files + generated error reports | daily | 7 daily · 4 weekly |
| `storage/app/public` | the company logo (Settings → Company) | weekly | 4 |
| `.env` + `APP_KEY` | secrets | manually, on every change, to a password manager | permanent |
| Source code | the git repository itself | every commit | permanent |

## 2. Mechanism

1. **Primary — [`spatie/laravel-backup`](https://spatie.be/docs/laravel-backup)**,
   run via the same single cron entry that drives `schedule:run` (see
   `docs/DEPLOYMENT.md` §9) — install and configure it as part of go-live
   provisioning, not before there is a real off-site destination to point it
   at:
   - Confirm `mysqldump` exists on the host first; if the host disables it,
     `spatie/laravel-backup` falls back to a pure-PHP dumper automatically.
   - Archives must be **password-encrypted** — they contain employee PII
     (names, emails, phone numbers).
   - Configure at least one destination that is **not** the same cPanel
     account: an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3), Google
     Drive via a Flysystem adapter, or SFTP to a different host.
2. **Secondary — cPanel's own Backup Wizard / JetBackup**, a weekly full
   account backup downloaded to company storage, independent of the
   application — it survives an application-level misconfiguration that a
   Laravel-driven backup would not (e.g. a broken `.env` preventing
   `schedule:run` from doing anything at all).
3. **Monitoring** — `spatie/laravel-backup`'s health-check notification
   emails on failure, and a weekly "backup healthy" confirmation email. A
   backup system nobody watches is a backup system nobody has verified is
   still running.
4. **Application-level safety net that is not a substitute for the above** —
   every business entity is soft-deletable (Assets, Employees, Departments,
   Locations, Categories, Warranties, Users), and the audit log is immutable
   and append-only. Most "I deleted the wrong thing" incidents are
   recoverable in-app, from the record's own Restore action, without
   touching a backup at all. This does not help against database corruption,
   host loss, or a bad migration — that is what §1–3 are for.

## 3. Restore procedure

Rehearsed **quarterly, on a staging subdomain** — a backup that has never
been restored is a hypothesis, not a safety net.

1. `php artisan down` — put the application in maintenance mode.
2. Create a fresh, empty database via cPanel → MySQL Databases.
3. Decrypt the chosen backup archive; import the SQL dump via the `mysql` CLI
   or phpMyAdmin into the new database.
4. Restore `storage/app/private` (and `storage/app/public` if the logo is
   also being recovered) from the archive.
5. Point `.env` at the restored database (`DB_DATABASE`, `DB_USERNAME`,
   `DB_PASSWORD` if they changed); `php artisan config:cache`.
6. Smoke-test exactly as in `docs/DEPLOYMENT.md` §6 step 14: sign in,
   Dashboard, Assets, one asset detail, Reports, Settings, Audit Logs.
7. Confirm the restored data's recency matches the backup's timestamp — spot
   check the newest row in `audit_logs` against when the backup was taken.
8. `php artisan up`.

## 4. What a restore drill should specifically confirm

Beyond "the app loads" — these are the details that a partial or stale
restore gets wrong silently:

- **Settings survive the restore.** Company name, logo, warranty alert
  configuration and every other row in `settings` come from the database
  dump, not from `SettingSeeder` — a restore that only re-runs migrations
  and seeders (rather than importing the actual dump) will silently reset
  every setting a master ever changed back to its seeded default.
- **The company logo file and the `company_logo` setting's stored path
  agree.** If only the database was restored (not `storage/app/public`),
  the sidebar will try to render a logo that no longer exists on disk —
  confirm it either renders correctly or falls back to the default mark,
  not a broken image.
- **Audit log continuity.** The restored `audit_logs` table should have no
  gap between the last entry before the incident and the restore point —
  a gap means the backup being restored is older than expected.
- **Generated-column invariants still hold** — spot check that no asset has
  two active assignments and no asset has two primary warranties (both are
  enforced by database-level unique constraints on generated columns, so a
  successful restore from a valid backup cannot violate them; this check is
  really confirming the *right* backup was restored, not a data integrity
  test of the schema itself).

## 5. Secrets are never in the encrypted database backup's blast radius

The backup archive's encryption password and `.env`'s `APP_KEY` are stored
separately, in the password manager referenced in §1 — never in the same
place as the backup archive itself, and never in git. Losing `APP_KEY`
without a copy makes every encrypted session and every encrypted cookie
unreadable; it does not affect the database dump's own encryption, which uses
a separate password.
