Sitewide Backups
A complete recoverable copy of the site captured on user demand. Backed by BackupService and surfaced at Dashboard → Backups.
What's in a backup
Each backup is a snapshot directory under storage/app/private/backups/snapshots/{timestamp}/ containing three subdirectories:
db/— the SQLite file (or a MySQLmysqldump).pages/— the user-editable blade content and the runtime-written config:views/pages/*.blade.php(top-level user pages only — never thedashboard/,auth/,errors/, orsettings/subdirectories, which are CMS code shipped with the install)views/shared-rows/views/layouts/partials/— the header/footer bladesLayoutServicewrites when the user picks a layout. Git-ignored and referenced by thelayout.default_header_slug/layout.default_footer_slugsettings, so a backup that omitted them would restore an install whose header/footer slug points at a blade that doesn't exist — the site renders with no header or footer.design-library/design-library-custom/— the install's own custom design-library rows + page bundles. These are git-ignored, so a backup is their only portability path between installs.themes-custom/andviews/theme-instances/— install-authored custom themes and composed theme instances. Git-ignored client-owned trees; a backup is their only portability path.routes/web.php(the client-zone routes only —routes/cms.phpis CMS code)config/navigation.phppage-data/— compiled per-page content sidecars (storage/framework/page-data/). These are derived fromcontent_overridesbut snapshotted alongside the blades so a restored site renders without first having to recompile. Pre-sidecar snapshots simply omit this directory; the restore path silently no-ops the copy when it isn't there.
files/— the media disk (storage/app/public/), excludingtmp/(AI-generation staging). Generated image variants are not backed up either: they live outside this tree entirely, atpublic/img/v/, and regenerate on demand.
Hardlink dedup — 10 backups, not 10× the disk
Media files are hardlinked between snapshots, not copied. When a file is unchanged across snapshots, every snapshot's reference to it shares the same on-disk inode. Ten backups of an unchanged 1 GB media library cost ~1 GB of disk, not 10 GB.
The Backups page surfaces this with a "Total disk used" footer that dedupes by inode and shows a green "Hardlinks save X" line comparing against the naive sum.
The first snapshot of a file copies it from live; subsequent snapshots compare size and mtime against the previous snapshot and hardlink when they match. This is the same model as rsync --link-dest and macOS Time Machine.
Why deletion is always safe
Hardlink refcount semantics make accidental data loss impossible: deleting any one snapshot only frees bytes whose refcount has reached zero. If snapshot A and B both reference the same inode, deleting A leaves B's data fully intact — there's no "stale" state to manage and no UI guard required.
The only place hardlinks don't apply is cross-filesystem cases (backups on a different mount than storage/app/public/), where the system falls back to copy(). Those snapshots own their bytes outright; deletion still frees only what they own.
Snapshots survive in-place media writes
A subtle correctness point: media files get hardlinked between snapshots, not from the live disk into a snapshot. If we hardlinked live → snapshot, an in-place write to live (which Storage::put does via file_put_contents with O_TRUNC) would mutate the snapshot too.
The actual flow: the first snapshot copies live → snap1; the second snapshot hardlinks snap1 → snap2 for unchanged files. Snapshots become inode-independent of live, so the user can rewrite, replace, or delete media freely without ever affecting historical snapshots.
Scheduled backups
Backups can run automatically on a recurring schedule from Dashboard → Backups → Scheduled Backup. It is on by default and DAILY by default — local snapshots are hardlinked incrementals costing seconds and near-zero disk, so a daily save point is essentially free (the expensive layer, the off-site upload, runs on its own weekly schedule below). The default time is staggered per install: a deterministic pseudo-random slot (00:00–03:45 on a 15-minute grid) derived from the install id, so a whole hosting server of standalone installs never backs up at the same instant. The operator can change the frequency, day, and time, or disable it entirely — an explicit save always wins over the staggered default (the form shows whichever will actually fire).
- Frequency — daily (default) or weekly.
- Day of week — only shown for weekly; 0 = Sunday through 6 = Saturday.
- Time —
HH:MMin the site timezone (site.timezone).
The scheduled backup runs through LazyCron, not Laravel's Schedule facade — the fleet has no * * * * * php artisan schedule:run cron (the only cron drives lazy-cron:run, and cron-less installs are driven entirely by the web-hit heartbeat). The backups:run-scheduled command is registered as a core LazyCron task in AppServiceProvider that ticks roughly hourly. Each tick asks BackupSchedule whether a backup is due: it computes the most recent scheduled "fire moment" (in the site timezone) and compares it against a backups.schedule_last_run_at marker. This catch-up shape means the backup is taken on the first heartbeat after the scheduled time passes — even on a quiet install — and never fires twice in one period. A failed backup does not advance the marker, so the next heartbeat retries (rate-limited to hourly by the LazyCron cadence). Disabling the toggle takes effect immediately (the command self-gates on backups.schedule_enabled). The manual "Back up now" button is independent and always works. See lazy-cron.md for how the rest of the recurring tasks are wired.
Three hardenings protect the web-heartbeat path (cron-less installs run LazyCron tasks post-response inside the FPM worker, where request_terminate_timeout can SIGKILL a long snapshot):
- Detached run preferred. When the due tick fires in a web context,
backups:run-scheduledhands the claimed run to a detached CLI subprocess (DetachedArtisan) — the same guard the update apply uses. Inline is the fallback only when no subprocess is possible. - Atomic snapshot builds.
createSnapshot()writes into a hidden.tmp-{timestamp}dir and renames it into place only when complete. A killed build can never appear restorable, seed the hardlink dedup, or rotate real snapshots out of retention; abandoned builds older than a day are swept on the next run. - Stale-status self-heal. Every
running/importingstatus write stampsbackup_status_at; the Backups page flips anything in flight for over 3 hours tofailedso a SIGKILLed run (which never reachesfailed()) can't wedge the UI.
Fleet-managed timing
When a site is part of a fleet, the fleet head (its license/fleet server) can schedule and stagger each managed install's backup so the whole fleet doesn't back up at once — see fleet-dashboard.md → "Backup scheduling" for the server side. The install side is a precedence ladder in BackupSchedule:
!enabled()→ never.- The admin opted to manage timing themselves (the switch on the Scheduled Backup card,
backups.self_managed) → the localbackups.schedule_*schedule wins. - A fleet slot was delivered (
backups.fleet_managed+backups.fleet_slot_day/_time, ingested from the Ed25519-signed membership response) → the install snapshots daily at that slot time when the block carriesfrequency: daily(a fleet head predating the daily cadence sends no frequency, and the slot keeps its legacy weekly day+time meaning), in its own timezone. - Otherwise → the local schedule / defaults.
A fleet-managed install fires at its slot two ways, de-duped by the shared backups.schedule_last_run_at marker: the fleet head nudges it precisely at the slot (a signed run-backup nudge to _cms/backup-nudge, run detached), and — if the fleet head can't reach it (e.g. under maintenance) — its own hourly heartbeat catch-up fires the same backup at the same persisted slot. RunScheduledBackup also takes a period-scoped Cache::add claim so the two paths can never double-back-up. When the fleet head additionally caps simultaneous runs per server (fleet-dashboard.md → "Run concurrency"; admission_managed in the signed schedule block), the local due-path waits for the nudge to arrive before running — the nudge's signed admitted claim stamps a per-period grant (backups.fleet_admitted_period) before its run spawns — bounded by the fleet's own 6-hour nudge window: past it the head would never nudge this period anyway, so the local fallback runs and a dark fleet head can never strand the daily save point. A human "Back up now" / --force bypasses the wait entirely. The install reports its site.timezone and last measured backup duration on the license poll so the fleet head can place its slot accurately; the same signed block also carries cms.fleet_auto_install_hour, which staggers the pre-update snapshot (see cms-updates.md).
The same signed block can also carry a provider-side backup destination (backups.fleet_dest_mode + backups.fleet_dest_url): the fleet owner can have each snapshot's bundle pushed to the fleet server's own disk or to the fleet's shared object bucket — a copy the site's own unix user (and therefore ransomware on the site) cannot reach: the grant is PutObject-only, there is no listing surface, and nothing the install sends decides which stored copies are retained (retention orders on the timestamp the FLEET SERVER observed, never on the caller-supplied filename; the prune runs only once bytes have actually landed; and grants are capped per install per day). What a compromised install can still do is upload junk bundles at its allotted rate, so a keep-N window can be churned forward by a determined attacker — pair a short retention window with bucket versioning or an object-lock policy if that matters to you. On the install these are ordinary off-site destinations (FleetDiskDestination / FleetBucketDestination) riding the existing bundle/upload queue, write-only by design, with nothing to configure locally — see fleet-dashboard.md → "Backup destinations". The site's own off-site card (its own S3/Dropbox/FTP) keeps working independently either way.
Retention
Two mechanisms keep the backup directory bounded without manual maintenance:
- Per-snapshot cap (
backups.retention_count, default 5, configurable from the Backups page) — auto-prunes the oldest snapshot when the cap is exceeded oncreateSnapshot(). Lowering the cap from the UI takes effect immediately rather than waiting for the next snapshot. The updater's recorded pre-update snapshot is PINNED (the timestamp in theupdate_last_snapshotSetting): with daily snapshots a keep-N cap spans only N days, so without the pin a "Roll Back to vX" clicked a few days after an update would find its restore point already rotated out. Both prune paths (pruneOldSnapshots()andpruneOlderThanDays()) honor the pin; retention may therefore hold cap+1 snapshots while a rollback point exists. - Bundle cleanup on delete — deleting a snapshot also deletes its prepared download bundle (a downloadable zip without its source snapshot is a footgun).
For ad-hoc per-snapshot deletion, the per-row trash icon on the Backups page handles individual snapshots. BackupService::pruneOlderThanDays() is also exposed for scripting / artisan use, but no UI surface for age-based sweeps — lowering the cap covers the same need.
Restore
- Full restore — overwrites the live DB, page files, page-data sidecars, and media disk with the snapshot's contents. Destructive; modal-confirmed.
- Per-page restore —
restorePageFromSnapshot(timestamp, pageSlug, bladeFilename)opens the snapshot's SQLite as a second connection, copies that page'scontent_overridesrows + the matching blade file back to live, then recompiles that page's sidecar from the just-restored DB state so the public render stays consistent on the next request. Other pages, the media library, and the rest of the site are untouched. Available from the page editor's discard dropdown ("Restore page from sitewide backup"). - Per-page restore is currently SQLite-only — extracting one page's data from a
mysqldumprequires SQL parsing that's out of scope. Full restore handles MySQL fine.
Download bundle
Backups stay as directories on the server by default. When the user wants to download one, the Prepare Bundle button zips the snapshot into storage/app/private/backups/bundles/{timestamp}.zip via Laravel's defer() (no queue worker required).
The bundle prep is atomic — written to a .partial file then renamed on success, with a .preparing sentinel file marking in-progress for the UI's wire:poll. The download route validates the timestamp shape against YYYY-MM-DD-HH-MM-SS (path-traversal guard) and 404s when the bundle isn't ready. A small "X" button on each ready bundle deletes it to free disk without touching the source snapshot.
Bundles can be re-uploaded via the "Restore from Uploaded Backup" form to migrate between installs.
Off-site destinations (S3 / Dropbox / FTP)
The newest backup is copied to remote storage once a week, on its own schedule, so a server failure can't take the backups down with it — and so the large upload (a full bundle zip, often hundreds of MB) never rides every daily snapshot or competes with the overnight maintenance window. An upload never takes the site offline, so its slot can sit anywhere in the 24-hour day; the OffsiteSchedule ladder mirrors BackupSchedule (fleet-assigned slot → the admin's saved Upload day/time on the Off-site Destinations card → a staggered per-install default on the 01:00–06:45 grid). Any snapshot can still be pushed manually at any time with its per-row "Push off-site" button. Three destinations ship built-in, all pure PHP (no external binaries — shared-hosting safe), configured from Dashboard → Backups → Off-site Destinations with per-destination enable toggles, a Test Connection button, and an independent remote retention cap:
- S3-compatible (
S3Destination) — Amazon S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, MinIO (optional custom endpoint + path-style toggle). Uses the already-vendored flysystem adapter; the AWS SDK's automatic multipart upload keeps multi-GB bundles out of PHP memory. - Dropbox (
DropboxDestination) — plain HTTP API, no SDK. Auth is the offline refresh-token flow: the admin creates a scoped Dropbox app (App-folder access,files.content.read/write), pastes the app key/secret, opens the authorize link, and pastes the one-time code back into the dashboard ("Connect Dropbox"). Large files upload through 8 MB upload-session chunks whose cursor is persisted as resume state. - FTP / FTPS (
FtpDestination) — ext-ftp with passive mode and explicit TLS. Uploads go to a.partfile via non-blockingftp_nb_fputand resume from the remote partial's size; the file is renamed into place only when complete.
What gets pushed. The snapshot's bundle zip — the same single-file format the "Restore from Uploaded Backup" flow consumes. OffsiteBackupManager auto-prepares the bundle when needed and deletes an auto-prepared bundle once every enabled destination holds a copy, so off-site pushes don't permanently double local disk. Remote files are named webprocms-backup-{timestamp}.zip; pruning and the remote browser only ever touch files matching that pattern.
How uploads run. The queue lives in the backups.offsite_state Setting. Two paths enqueue on the WEEKLY cadence, both gating on OffsiteSchedule::isDue() and stamping the period: CreateBackupJob right after a snapshot when the off-site moment has already passed (the weekly push rides the freshest snapshot, and gets a generous first push post-response), and the backups:offsite LazyCron tick (every 5 minutes), which enqueues the newest snapshot when the slot arrives with no snapshot event to ride — this is what lets the upload start at its own time of day, decoupled from snapshot creation. Leftovers are advanced by the same tick and by the Backups page's wire:poll while an admin is watching — the budgeted, resumable state-machine pattern the SEO scanner uses, so no queue worker is required. A cache lock serializes the drivers. Failures record the error on the snapshot row's badge, retry up to 5 times with a 10-minute cooldown, and can be retried immediately with the per-snapshot "Push off-site" button. Off-site failures never fail the snapshot itself.
Restore from off-site. The "Off-site Backups" card lists what's stored at each destination — including backups already pruned locally. Restore downloads the zip into the imports directory and hands it to the existing ImportBackupJob upload-restore path (same traversal-guarded extraction); Delete removes the remote copy after a modal confirm.
Database driver support
- SQLite — full support, including per-page restore.
- MySQL — full snapshot via
mysqldumpand full restore viamysqlimport. Per-page restore not yet supported (would require a SQL parser).
Page Revisions
Per-page snapshots of content_overrides captured automatically on every save, available for one-click rollback from the page editor. Backed by PageVersionService and the page_override_versions table.
Designed to avoid the WordPress revisions trap
WordPress historically gets criticized for unbounded wp_posts revision rows accumulating over years. WebProCMS bakes three pressure-relief mechanisms in from day one:
- Throttle on save (
page_versions.throttle_seconds, default 60) — sequential saves by the same user inside the throttle window update the existing version in place instead of inserting a new row. A two-hour editing session with rapid saves collapses into one revision, not dozens. - Cap per page (
page_versions.cap_per_page, default 30) — auto-prunes the oldest version on insert when the cap is exceeded. Mathematically bounds the table atcap × #pagesrows. A 100-page site with 30 versions each = 3,000 rows max, regardless of how long the install runs. - Manual sweep by age — Tools page action calls
pruneOlderThanDays()for ad-hoc cleanup beyond the cap.
Storage
payloadis gzipped JSON of everyContentOverriderow for the page (row_slug+key+type+value). Compresses ~80–90% smaller than raw JSON.payload_size_bytesrecords the uncompressed size for display.override_countis the number of overrides captured.created_by_user_idrecords who saved the version (FK with null on delete).
A typical page with 100 overrides totals about 1.5–2 KB compressed per version. 30 versions × 100 pages × 2 KB ≈ 6 MB — trivial even on shared hosting.
Restore
PageVersionService::restore(versionId, userId) wraps the operation in a DB transaction and:
- Snapshots the pre-restore state as a forced new version (bypassing the throttle), so the user can immediately undo the restore itself.
- Deletes every current
content_overridesrow for that page. - Inserts the version's payload back into
content_overrides.
Both restore actions live in the page editor's discard / reset dropdown — same place the user already goes for "Discard unsaved changes" and similar resets.
Toggle
Setting page_versions.enabled to false makes snapshot() a no-op. The save flow proceeds without versioning. Disabled installs can re-enable any time without data migration.
What lives where
| Path | Purpose |
|---|---|
app/Services/BackupService.php |
Snapshot lifecycle: create, list, delete, restore (full + per-page), bundle prep/download. |
app/Services/PageVersionService.php |
Per-page versioning: snapshot, restore, list, prune by cap or age. |
app/Models/PageOverrideVersion.php |
Eloquent model. Reading payload goes through the service. |
app/Jobs/CreateBackupJob.php |
Thin wrapper that delegates to BackupService::createSnapshot() and owns the Setting::backup_status flag. |
app/Jobs/ImportBackupJob.php |
Thin wrapper for full restore (snapshot timestamp or uploaded zip). |
app/Support/Backups/OffsiteBackupManager.php |
Off-site push orchestration: queue state, bundle prep/cleanup, remote prune, remote list/download/delete. |
app/Support/Backups/OffsiteDestination.php |
Destination interface (+ S3Destination, DropboxDestination, FtpDestination siblings). |
app/Console/Commands/BackupsOffsiteCommand.php |
backups:offsite — the LazyCron tick that advances pending uploads. |
app/Concerns/EditorDiscardActions.php |
Trait on the page editor that adds the restore-from-version + restore-page-from-backup actions. |
resources/views/pages/dashboard/⚡backups.blade.php |
Backups dashboard. |
resources/views/pages/dashboard/⚡tools.blade.php |
Tools page sweep buttons (page versions by age + media orphans). |
Settings reference
| Key | Default | Notes |
|---|---|---|
backups.retention_count |
5 |
Auto-prune on createSnapshot() past this many snapshots. The pre-update snapshot named by update_last_snapshot is pinned — never pruned. |
backups.schedule_enabled |
true |
Master toggle for the scheduled backup task. On by default; re-checked at run time. |
backups.schedule_frequency |
daily |
daily or weekly. |
backups.schedule_day |
staggered | Day of week for weekly; 0 = Sunday through 6 = Saturday. Unset = a per-install staggered default derived from the install id. Ignored for daily. |
backups.schedule_time |
staggered | Time of day in HH:MM form, site timezone (site.timezone). Unset = the staggered default (00:00–03:45, 15-minute grid). |
backups.schedule_last_run_at |
0 |
Unix timestamp of the last scheduled run; de-dupe marker so a period fires once. |
backups.offsite_day / _time |
staggered | The admin's WEEKLY off-site upload slot (Off-site Destinations card). Unset = a per-install staggered default (01:00–06:45, 15-minute grid, its own hash salt). |
backups.offsite_schedule_last_run_at |
0 |
Unix timestamp of the last off-site enqueue; the weekly period's de-dupe marker. |
backups.last_duration_s |
0 |
Real measured duration of the last incremental snapshot; reported to the fleet head so it can space slots by reality. The first-ever (full-copy) snapshot never reports — it reads several times slower than the steady state. |
backups.last_duration_at |
'' |
When that duration was measured. Sent with the reading so the fleet head decays its estimate once per new backup, not once per poll. |
backup_status_at |
'' |
When backup_status last entered running/importing; drives the 3-hour stale-status self-heal. |
backups.self_managed |
false |
The admin opted to manage timing locally, overriding any fleet slot. |
backups.fleet_managed |
false |
The fleet head is scheduling this install (ingested from the signed membership response). |
backups.fleet_slot_day / _slot_time |
— | The fleet-assigned slot (0–6, HH:MM), interpreted in the install's own timezone. Daily (time-of-day) when backups.fleet_frequency is daily; legacy weekly otherwise. |
backups.fleet_frequency |
'' |
daily when the fleet head runs the daily-snapshot cadence; empty under an older fleet head (legacy weekly slot meaning). |
backups.fleet_offsite_day / _offsite_time |
'' |
The fleet-assigned WEEKLY off-site upload slot (24-hour canvas, packed quiet-hours-first on the fleet head). Empty = fall back to the fleet backup slot, then local. |
backup_nudge_received_at |
— | Timestamp of the last verified run-backup fleet nudge. |
backup_status |
idle |
UI flag — running / importing / failed / idle. |
backup_error |
'' |
Message from the last failed backup or restore. |
backups.offsite_state |
[] |
Off-site upload queue + auto-bundle bookkeeping (managed by OffsiteBackupManager; not hand-edited). |
backups.offsite.s3.* |
— | enabled, key, secret, region, bucket, endpoint, prefix, path_style, keep. |
backups.offsite.dropbox.* |
— | enabled, app_key, app_secret, refresh_token (written by the Connect flow), folder, keep. |
backups.offsite.ftp.* |
— | enabled, host, port, username, password, path, passive, tls, keep. |
backups.offsite.{dest}.keep |
4 |
Remote retention per destination — newest N kept (≈ a month of weekly copies), 0 = keep all. Only CMS-named files are pruned. |
page_versions.enabled |
true |
Master toggle for per-save snapshot capture. |
page_versions.cap_per_page |
30 |
Per-page rollover cap. |
page_versions.throttle_seconds |
60 |
Same-user same-page saves inside the window update the existing version. |