Skip to main content

Documentation

No results found.
Features

Backups & Page Revisions

WebProCMS ships with two complementary recovery systems: sitewide backups for disaster recovery and page revisions for everyday undo. They're designed to do different jobs without overlapping responsibilities.


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 MySQL mysqldump).
  • pages/ — the user-editable blade content and the runtime-written config:
    • views/pages/*.blade.php (top-level user pages only — never the dashboard/, auth/, errors/, or settings/ subdirectories, which are CMS code shipped with the install)
    • views/shared-rows/
    • views/layouts/partials/ — the header/footer blades LayoutService writes when the user picks a layout. Git-ignored and referenced by the layout.default_header_slug / layout.default_footer_slug settings, 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/ and views/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.php is CMS code)
    • config/navigation.php
    • page-data/ — compiled per-page content sidecars (storage/framework/page-data/). These are derived from content_overrides but 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/), excluding tmp/ (AI-generation staging). Generated image variants are not backed up either: they live outside this tree entirely, at public/img/v/, and regenerate on demand.

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:MM in 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-scheduled hands 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/importing status write stamps backup_status_at; the Backups page flips anything in flight for over 3 hours to failed so a SIGKILLed run (which never reaches failed()) 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:

  1. !enabled() → never.
  2. The admin opted to manage timing themselves (the switch on the Scheduled Backup card, backups.self_managed) → the local backups.schedule_* schedule wins.
  3. 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 carries frequency: 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.
  4. 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:

  1. Per-snapshot cap (backups.retention_count, default 5, configurable from the Backups page) — auto-prunes the oldest snapshot when the cap is exceeded on createSnapshot(). 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 the update_last_snapshot Setting): 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() and pruneOlderThanDays()) honor the pin; retention may therefore hold cap+1 snapshots while a rollback point exists.
  2. 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's content_overrides rows + 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 mysqldump requires 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 .part file via non-blocking ftp_nb_fput and 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 mysqldump and full restore via mysql import. 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:

  1. 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.
  2. 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 at cap × #pages rows. A 100-page site with 30 versions each = 3,000 rows max, regardless of how long the install runs.
  3. Manual sweep by age — Tools page action calls pruneOlderThanDays() for ad-hoc cleanup beyond the cap.

Storage

  • payload is gzipped JSON of every ContentOverride row for the page (row_slug + key + type + value). Compresses ~80–90% smaller than raw JSON.
  • payload_size_bytes records the uncompressed size for display.
  • override_count is the number of overrides captured.
  • created_by_user_id records 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:

  1. Snapshots the pre-restore state as a forced new version (bypassing the throttle), so the user can immediately undo the restore itself.
  2. Deletes every current content_overrides row for that page.
  3. 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.