Skip to main content

Documentation

No results found.
Features

CMS Updates

WebProCMS ships with a built-in update system: every install checks for new versions hourly (a CDN-cached beacon read, backed by a full signed poll every 6 hours — see "Discovery within the hour"), surfaces an "update availab...

WebProCMS ships with a built-in update system: every install checks for new versions hourly (a CDN-cached beacon read, backed by a full signed poll every 6 hours — see "Discovery within the hour"), surfaces an "update available" banner on the dashboard homepage, and applies the update with a single click — or automatically, if the operator opts in. Updates are delivered from the webprocms.com mothership over the per-install license-key channel (clients never touch GitHub). They're applied by a pure-PHP package engine — download a verified zip and extract it, no git/composer/shell required — which works on the cheapest shared hosts as well as a managed VPS.


How it works

Three pieces, all built into the CMS:

  1. Update check. The install's license phone-home, which runs every 6 hours — and right away whenever the hourly beacon read names a release it doesn't know (MembershipClient → CMS_MEMBERSHIP_API_URL, authenticated with the install's CMS_LICENSE_KEY), returns this install's paid status and an update block — the version, notes, license-gated download URL, and sha256 of the build the mothership offers. It's stored in settings (UpdateChecker::ingest()). One combined poll carries both entitlement and update info; there is no separate releases feed. cms:check-updates then applies a pending update when auto-install is on.
  2. Dashboard banner. When the offered version is newer than the install's VERSION file, an amber "CMS update available" banner appears at the top of the dashboard homepage for admins. One click takes them to Dashboard → Tools → CMS Update.
  3. Apply. The Tools page shows the version delta + release notes, and the operator clicks "Update Now." The update runs as a detached background process — no queue worker required. If they prefer hands-off updates, the Auto-install updates toggle on the same card makes the scheduled job (which runs every 6 hours) apply updates automatically as soon as they're detected.

There is one engine, and it is the pure-PHP package pipeline described under How an update is applied. A second, git-based engine (git pull + composer install + npm run build) existed until 1.2.19; it was removed once the runtime CSS supplement made the Node build step unnecessary and every install — including the mothership — had settled on the package engine. Keeping a rarely-exercised second route through the most safety-critical code in the product cost more than it bought. A leftover CMS_UPDATE_ENGINE in an install's .env is ignored.

While the job runs, the card reads its progress from a flat status file the web server can serve without booting the application (see Live progress) and surfaces the full command log on completion or failure.


Why this isn't a naive zip-overwrite

Most PHP CMSes (WordPress, Joomla, Drupal in some configurations) ship updates as zip archives — the dashboard downloads a zip from the vendor, extracts it over the install, and runs migration hooks. That model is friendlier for shared hosting (FTP-only, no shell access), but the naive version has real disadvantages that compound at scale.

WebProCMS ships zips too — but the pipeline around them is built to avoid each of those failure modes: staged-and-validated extraction for atomicity, mandatory sha256 plus an offline signature for provenance, and a pre-update snapshot paired with a retained prior zip for rollback. What that buys:

Nothing is touched until a complete build exists

Download, checksum, unzip, and structural validation all happen in a staging directory. Only after a verified, self-contained build is sitting on disk does anything overwrite a live file. The naive model extracts straight over the install, so an interruption mid-way (PHP timeout, server kill, disk full) leaves some files new and some old — WordPress's 4.0 auto-update is the frequently-cited case study.

The residual risk is an interruption during the copy itself, which is why /recover.php exists and why place() is delta-aware: it skips byte-identical files, so the window where the tree is mixed is the size of the release delta rather than the whole 22k-file build.

Only the changed bytes, most of the time

A release is a complete build, but two things keep the transfer small. vendor/ — roughly two thirds of the artifact — ships as a separate package that is skipped entirely when composer.lock hasn't moved, and place() only writes files that actually differ. A typical patch release is a ~11 MB download that rewrites a handful of files.

Real rollback, including the database

Each successful update retains its own zip, and every update takes a pre-update snapshot first. A failure before migrations auto-re-places the retained zip; a failure at or after migrations offers a human-confirmed Roll Back to vX that restores code and snapshot together — because rolling code back without the database is how a "rollback" becomes a second outage. The naive model has neither half.

Update-safe customization (no fork)

WebProCMS is a private, proprietary product — there is no fork model. Per-client customization is delivered without touching core, so updates still apply cleanly:

Ship custom work as a website-specific feature module (app/CustomFeatures/*) that the client installs and updates independently of the core CMS. See Feature updates below.

Custom design-library rows an install creates (Save your own rows) are inherently update-safe too: they live in a tree outside the files the updater manages, so the updater never collides with or overwrites them — no feature module required. The protected-path list (.env, storage/, content, per-install customizations) always wins over the release manifest.

Verified provenance

A tampered zip on a CDN is invisible to the operator, so the build is not trusted on its own: the install verifies the sha256 the signed membership response carried, and under signature enforcement also checks an offline Ed25519 signature made on the owner's machine. A compromised mothership can serve bytes but cannot forge that signature.

Cheap distribution

The package build is served as a single static file from the mothership behind an unguessable, license-gated URL. Because the bundle is identical for every install, it sits behind Cloudflare's CDN — one cached object fans out to every install, so distribution stays effectively free regardless of how many sites pull it. (Cloudflare R2 is the escape hatch if bandwidth ever outgrows that.)

Discovery within the hour — the release beacon

Discovery used to be the 6-hour signed poll alone, so "critical" only ever skipped the channel soak and the overnight window: a critical release still took up to seven hours to reach a standalone install, and an agency fleet head — the bottleneck for its whole fleet — learned no faster. Since 2026-09-09 cms:publish-release also writes a keyless static public/dl/beacon/latest.json ({version, critical, published_at}), and every install's hourly cms:check-updates tick reads it from the CDN (ReleaseBeacon). When it names a version the install doesn't know, the install runs its normal signed poll right then — immediately for a critical release, or once its own channel soak has elapsed for a normal one (so 10,000 Standard installs don't hit the signed endpoint the minute a release drops) — and at most once per beacon version. A fleet head that just learned of a critical release kicks its scheduler in the same tick, so its installs queue behind the run cap at once. The beacon is a hint only: verdicts, update blocks, the soak and the breaker all stay on the signed poll, and a malformed or unreachable beacon is "no signal". It lives in its own /dl/beacon/ subdirectory so a CDN rule that caches the token-named zips forever (/dl/*.zip) can never pin it; .htaccess gives it Cache-Control: public, max-age=300. Cost at scale is zero origin work — a handful of edge revalidations an hour however many installs poll. CMS_RELEASE_BEACON_URL overrides the derived URL (the license server's origin + /dl/beacon/latest.json).

Release channels without parallel artifacts

Early access / Standard / Cautious are a serve-side soak, not separate builds: one published release is simply withheld from the slower channels until its delay has elapsed, and the prior retained release is offered meanwhile. Every install on a given version runs byte-identical code.


Configuration

These .env keys control the update system:

CMS_LICENSE_KEY=...                                       # per-install key issued by the mothership
CMS_MEMBERSHIP_API_URL=https://www.webprocms.com/api/membership/check

Update info arrives in the membership/update poll (every 6 hours, or within the hour when the beacon flags a new release), not a separate releases feed (CMS_RELEASES_API_URL is retired — a private repo can't be polled anonymously). The mothership returns an update block — {version, notes, notes_since, download_url, sha256} — gated to recognized installs; the install records it and the package engine downloads + verifies + extracts it.

The notes cover every release the install hasn't seen. The poll already reports the caller's version, so ReleaseChannel::cumulativeNotes() composes the block's notes from every resources/changelog.json entry in (reported, offered] — newest first, each under a v{version} header — and sets notes_since to the reported version so the update card titles itself "What's new since v1.0.160" instead of naming only the newest build. An install four versions behind used to see only the newest release's notes, which is exactly the information it can't act on. Rules worth knowing:

  • The offered release's own section is the published release.notes (what the owner actually shipped); the changelog only fills it in when nothing was published, and supplies the in-between releases, which have no other source.
  • A single-release span, a caller that reports no version, and an older mothership that sends no notes_since all render exactly as before — one block of notes titled "What's new in v{latest}".
  • Beyond ReleaseChannel::MAX_CUMULATIVE_NOTE_VERSIONS (10) the tail is summarised as a count rather than pushed through the signed response.

Because the composition happens on the mothership, installs already in the field get the fuller notes on their next poll — no client update needed first. notes_since is purely additive on the wire; clients predating it ignore the key.

Auto-install behaviour is controlled by Setting::get('cms.auto_install_updates'), exposed as a switch on the Tools page. Default: off (manual click only).


Two update modes — operator choice

The dashboard banner alerts the operator that an update is available. From there they pick the experience that fits how they run their site:

  • Review & install (default). The operator sees the version delta, reads the release notes inline, can take a backup first if they want, and applies the update on their own schedule. One click.
  • Auto-install. Flip the Auto-install updates toggle on the Tools page and the scheduled job (every 6 hours) applies updates automatically as soon as they're detected. The dashboard still shows what changed, but no click is required. An optional Install time (cms.auto_install_hour) defers the scheduled apply to the six-hour window starting at that hour in the site timezone (site.timezone, Settings → Business Info). Timing chain: update discovery is the 6-hourly membership poll (network); the apply tick (cms:check-updates, pure local Setting reads) runs hourly, so a 2 AM setting applies at roughly 2–3 AM local — the window's six-hour width is slack for sparse-traffic LazyCron drift, not the expected apply time. The first in-window tick also refreshes the mothership poll (≤ once per hour, failure falls back to cached data), so a release published in the evening still makes that night's window instead of slipping a day behind the free-running discovery poll. Because LazyCron's auto mode is visitor-triggered, a site with zero overnight traffic could miss the window every day; after 24 hours of misses the next tick applies regardless (update_window_deferred_since) — one oddly-timed install beats a security update that never lands. The brief §D.1 maintenance page then lands overnight. Manual Update Now / --apply is never delayed, and an out-of-window tick doesn't flag the update, so the admin-dashboard pickup inherits the window automatically.
    • Fleet-controlled timing. A pre-update snapshot is a backup, so on a fleet-managed install the update-apply is nudged at the install's staggered backup slot — the fleet head sends a check-updates nudge to each outdated install at its slot (preferred over the backup nudge that tick), so the pre-update snapshots stagger across the fleet exactly like the weekly backups instead of all firing when a release drops. The staggered nudge supersedes only the timing, never the operator's choices: it is sent exclusively to installs that report auto-install ON (auto_update_enabled on the check-in), and it carries mode: scheduled inside the signed claims, which the receiver maps to cms:check-updates --apply-scheduled — a fresh poll that then applies only what auto-install would apply anyway (toggle + minor/patch gates enforced, window bypassed because the nudge is the window). A manual-mode ("Review & install") install is never remote-applied; the human "Update now" button on the fleet page remains the deliberate go-now --apply override. The update system's own claim means a nudge never double-applies against the autonomous path. As a mothership-down fallback, the autonomous window hour also comes from the fleet head: CmsUpdate::autoInstallHour() prefers cms.fleet_auto_install_hour (delivered in the signed membership response) over the local cms.auto_install_hour when fleet-managed. Critical updates still bypass the window entirely (though not the fleet run cap below); a self-managed admin keeps their own hour; and a successful update marks the week's scheduled backup satisfied so it isn't doubled up. See fleet-dashboard.md → "Backup scheduling".
    • Run admission (the per-server concurrency cap). When the fleet head caps simultaneous runs per physical server (fleet-dashboard.md → "Run concurrency"), a fleet-managed install's auto path additionally waits for an admission grant before applying: the signed schedule block carries admission_managed, and the grant arrives either inside the scheduled nudge's signed claims (admitted — written as a time-boxed cms.fleet_admitted_until Setting before the nudged run spawns, so the gate passes) or, for a critical release waiting on a busy server, via a signed admission block on the combined poll — the gated install re-polls for it at most hourly, so critical gets in line now instead of waiting for tonight, but never exceeds the cap. Three carve-outs keep this safe: a human Update Now / --apply never consults admission; a standalone or self-managed install reads the gate as off and keeps full autonomy (critical included); and an install that has awaited admission for 24 h applies anyway (update_admission_deferred_since, the same deferral-cap shape as the install window) — a dark mothership can never strand an update. claimRun() consumes the grant, so a failed apply's retry needs a fresh admission rather than sidestepping the cap.

Both modes run the same pipeline. The toggle just controls who pulls the trigger.

Live progress on the Updates page

While update_status is running, the CMS Update card polls every 3 seconds and shows two things:

  • A stage bar — percent + label, derived purely from the update_log lines the engine has already written (UpdateProgress::resolve()). A rollback renders an indeterminate bar instead — its line set is short and dominated by one long extract-and-restore span.
  • A detail line beneath it — "what is it doing right now", refreshed many times inside a single stage: bytes downloaded (Downloading the release package — 12.4 MB of 48.9 MB (25%)), files unpacked, files checked during place(), and the plain-English name of each post-install step. Its whole purpose is to keep changing, so a long stage never reads as a frozen page.

The detail line lives in its own update_detail Setting (never in update_log — it is overwritten constantly and is meaningless once the run ends). It is fed by CmsPackageUpdater::onDetail(), which the job wires to UpdateProgress::record(), and is cleared when a run is claimed, whenever a new log line supersedes it, and when the run ends.

Both are mirrored to a flat file the browser can read without the application. Every one of these values lives in the database, reachable only through a Livewire round-trip — and the span they narrate is exactly when the install cannot serve one, because its files are half-swapped and its PHP workers are recycling. UpdateStatusFile writes the same status to public/cms-status/{token}.json, served straight off disk, and the card polls that instead; it reloads itself once the file reports a terminal state. The filename carries a per-run random token so update state isn't public, and if the mirror can't be written the card falls back to polling the application as before.

Three properties this had to hold, because it rides the self-hosting updater:

  • Every emission is throttled (≤1/second) — the hot callers fire per downloaded chunk and per copied file, and each write is a DB round-trip. Once-per-phase announcements bypass the throttle so a phase always names itself immediately.
  • Every emission is swallowed. detail(), UpdateProgress::record() and the download callback each catch Throwable. A cosmetic status line must never be able to abort an update.
  • The download hook is opt-in. ArchiveDownloader::toTempFile() attaches Guzzle's progress request option only when a sink is passed, so the feature-module and asset-bundle download paths are byte-for-byte unchanged. Handlers that don't support the option ignore it.

Counting note: place() reports files checked, not copied. The copy is delta-aware, so most of the ~22k shipped files are byte-identical and skipped — a copied-only counter would sit still through exactly the stretches this line exists to narrate.

Maintenance window around the destructive span

Both engines put the site into maintenance mode for exactly the span where serving traffic is dangerous — from the moment live files start changing (package place(), git merge) until migrate finishes — and lift it before the tolerant tail (CSS regen, reseeds, cache rebuild). The package engine's placement is delta-aware: it compares each staged file against the live copy (size, then hash) and writes only what actually changed between releases — typically dozens of files out of ~22k — so the window is usually a few seconds rather than the minute a full-tree copy took. Skipped files keep their inode and mtime, which OPcache and every mtime-keyed memo read as "unchanged" (because it is). Before this, visitors executed a mixed old/new tree for the whole copy, then new code against the old schema until migrate completed. Mechanics (UpdateMaintenance):

  • The 503 is pre-rendered (down --render), so public/index.php's storage/framework/maintenance.php short-circuit serves it before any application code loads — a mid-copy tree can't 500.
  • A random bypass secret is generated per window; the admin who clicked Update Now gets the framework's bypass cookie queued on that same response, so their status poll keeps working throughout.
  • Every exit path is guarded (post-migrate exit + finally), and a window stranded by a SIGKILLed process is lifted three independent ways: the admin-dashboard middleware's stale-heal (the triggering browser's cookie still reaches the app), /recover.php (clears the down files frameworklessly), or php artisan up over SSH. The heal only ever lifts a window whose recorded secret matches — a manual artisan down is never touched.
  • Opt out with CMS_UPDATE_MAINTENANCE=false (site stays live throughout, the pre-2026-07 behavior).

Where the apply runs (never a visitor's request)

The scheduled cms:check-updates tick is invoked by LazyCron inside a random visitor's FPM worker. In that context the command never applies — an FPM apply is subject to request_terminate_timeout SIGKILL, which skips failed() and wedges the claim. Instead it flags the eligible update (update_auto_apply_pending Setting) and hands the apply to a real CLI:

  1. Detached subprocess (hosts with proc_open + a PATH php): nohup php artisan cms:check-updates & — the child re-verifies eligibility in console context and applies inline, immune to FPM timeouts.
  2. Admin-dashboard pickup (shared hosts with no subprocess path): the ApplyPendingCmsUpdate middleware applies the flagged update on the next Admin's full-page dashboard request via post-response defer() — the same path as the Update Now button. Eligibility is fully re-verified at pickup; a stale flag is cleared. Public requests pay two string checks.

Real-cron installs (lazy-cron:run in a crontab) and SSH runs are console context and apply inline as always. Claiming the run slot clears the pending flag, so no path can double-apply.

Prior-version rollback (package engine)

Every successful package update retains its own zip at storage/app/private/cms-update/releases/v{version}.zip (exactly one — the currently-installed release, which is by definition what the next failed update rolls back to; storage/ is never touched by updates). Two rollback paths use it, split by where the failure happened relative to migrate:

  • Automatic, for place() failures. A failure during file placement happens before migrations, so the schema is still the prior release's — CmsPackageUpdater::attemptAutomaticRollback() re-places the retained zip on the spot (under the still-active maintenance window) and the site comes back on the prior version with the database untouched. The original failure still marks the update failed; the log records the rollback.
  • Manual one-click, for migrate-or-later failures. Rolling back code alone after migrate ran would put old code on the new schema — potentially worse than the failure. So the Updates page's failed card offers Roll Back to vX (also php artisan cms:rollback), which re-places the retained zip and restores the pre-update snapshot the updater recorded (update_last_snapshot) — database, runtime pages, and media together, via CmsRollback. Because the §D.1 maintenance window blocked public traffic between snapshot and failure, the restore's data-loss exposure is effectively nil — but it is still a restore, so it is always human-confirmed. --code-only skips the snapshot half on the CLI. The run finishes on a distinct rolled_back status.

Caveats: an install only has a retained zip after its first successful package update running this code (before that, the failed card points at Backups + /recover.php as before); files added by the failed new release are not deleted by a rollback (harmless orphans — the old autoloader never references them); git-engine installs have no retained zip and roll back with git.

Fleet safety rails (§D.4)

Five controls between a published build and the whole fleet running it (full operator runbook in releasing.md): releases are signed offline on the owner's laptop (installs verify version|sha256 against a baked-in public key before applying — enforcement ON by default since every offerable build is signed; CMS_RELEASE_VERIFY_SIGNATURE=false opts out, and the retained-zip rollback path is exempt so recovery works offline); a critical release bypasses install-hour windows and channel soaks; every install picks a self-service update channel (and the fleet owner can override a specific install's channel from the Fleet drill-down); and every install probes its own homepage after applying and phones the outcome home (a fresh php artisan cms:probe subprocess, so the verdict comes from the code on disk rather than the updater process's pre-update route table; in-process render is the fallback where no subprocess is possible) — enough distinct failures trips a circuit breaker that rolls the fleet's offer back to the prior retained release until the next publish.

Self-service update channels

Every install chooses how quickly it receives new releases, from the Updates page dropdown (Settings → Updates → Update channel; cms.update_channel Setting, sent on every membership poll as update_channel):

Channel Soak Meaning
Early access 0 h Offered new releases the moment they're published — the first-updater cohort
Standard (default) 24 h Offered a release once it has aged a day on early installs
Cautious 72 h Extended soak for risk-averse sites

The hours live in ReleaseChannel::CHANNELS; the gate is serve-time in ReleaseChannel::updateBlockFor() — a non-early install is offered the new build only once release.published_at + its channel's delay has passed; until then it's offered the prior retained release. No per-publish flag is needed, so every release automatically gets a soak period. Semantics and precedence:

  • Critical releases skip the soak entirely — a security fix reaches everyone on their next poll.
  • The fleet owner's per-install Speed override (the Fleet drill-down select, member_installs.update_channel_override) wins over the install's own channel choice — forcing Early makes a site a first-updater, Cautious slows a fragile one down. (This replaced the old is_canary flag and the publish-time canary window, both removed 2026-07 as redundant with the soak.)
  • The tripped circuit breaker overrides everything, including Early and critical.
  • Older clients that don't send update_channel are served as Standard. The channel is a risk preference, not an entitlement — both the new and prior builds are signed releases.
  • An install mid-soak that clicks Check for Updates simply sees "latest version" (the prior offer never version-compares above its current build — no downgrade is ever offered as an update). The Updates page notes this and points at Early access.
  • The mothership records each install's reported channel on its member_installs row; the Fleet page shows Early/Cautious badges per install and an Early cohort stat (flagged canaries + self-selected early) on the Rollout tab — if that number is ~0, soak windows are pure latency with nobody proving builds out, so keep at least the owner's own installs on Early access.

Result notifications (email + dashboard notice)

Updates never finish silently — critical with auto-install, where nobody clicked anything. When either engine reaches its complete/failed point, UpdateNotifier::record() does three things:

  • Dashboard notice. The outcome (status, version, timestamp, failure excerpt) is persisted in the update_last_result Setting and rendered as a dismissible banner on the dashboard home (green "CMS updated" / red "CMS update failed") and as a "Last update" line on the Tools page CMS Update card. It survives the Tools page's transient status reset and stays until an admin dismisses it.
  • Update history. Every result is prepended to the rolling update.history Setting (JSON array, newest first, capped at 20 — same shape discipline as the mothership's release.history), storing status, version, at, the applied release's notes, and the failure log_excerpt. History accumulates even with emails off; it feeds the Recent updates list on the Updates page and the digest email below.
  • Admin email. Every admin-or-higher user is emailed. Controlled by the Email admins after updates switch (cms.update_notify_email Setting, default on) plus an Email frequency select (cms.update_notify_frequency: instant / daily / weekly, default weekly) on the Updates page. Email failures are logged and never fail the update itself.

Frequency semantics — a throttle, not a calendar week. Owner releases can ship several times a day; with auto-install that would mean several emails a day, so digest is the default:

  • instant — every result emails as it happens (CmsUpdateResultMail), today's pre-1.0.124 behavior.
  • daily / weekly — at most one success email per 24 h / 7 d window, measured from the last notification email (cms.update_notify_last_sent_at watermark). The first completed update after a quiet window emails immediately (CmsUpdateDigestMail, covering everything un-notified) and restarts the window from that send; updates inside the window accumulate silently in update.history. The hourly updates:notify-digest LazyCron tick flushes the accumulated batch once the window elapses, so nothing waits longer than one window past the last email. Pending entries older than 30 days never email (guards flipping a long-disabled toggle back on).
  • Failed updates always email immediately (CmsUpdateResultMail with the failure reason + pre-update-snapshot restore hint), whatever the frequency — a broken auto-update must not sit in a digest for days. Failure alerts never touch the digest watermark.

How an update is applied

The pipeline depends only on what every WordPress-capable host already has: ZipArchive, cURL/streams, and the standard file functions. No git, no composer, no proc_open() — the cheapest WordPress-style shared hosts (GoDaddy, Hostinger, Network Solutions, Bluehost) disable the latter, and a managed VPS gains nothing from it.

The pipeline (CmsPackageUpdater, run by UpdateCmsPackageJob):

  1. Pre-update snapshot via the backup system — the rollback point.
  2. Download the release zip (streamed to disk, never buffered in memory; https-only; size-capped) and verify its SHA-256 — a mismatch refuses to install.
  3. Extract to a staging dir with path-traversal guards, stripping the single wrapper folder GitHub archives use.
  4. Validate the staged tree is a complete, self-contained build (public/index.php, vendor/autoload.php, artisan, VERSION). A raw source zipball — which lacks vendor/ — is rejected here, before any live file is touched.

Vendor-split delivery (2026-08)

vendor/ is roughly two thirds of the compressed release and changes only when composer.lock does — rarely. When the signed update block carries the additive main_url / main_sha256 / main_signature / vendor_url keys, a split-aware install replaces step 2–4 with:

  • Download the app-only zip (-main, ~1/3 the size), verified against main_sha256 and — under signature enforcement — the main_signature (its own offline signature; the combined one covers different bytes). On any doubt (keys missing, unverifiable signature) the install silently falls back to the combined zip, so the split can never block an update.
  • Read the vendor block from the app zip's manifest — the vendor zip's sha256 plus a deterministic vendor content hash (sorted relative paths + per-file sha256; not the zip bytes, which differ between builds even for identical content). Because the manifest sits inside the offline-signed app zip, the vendor zip inherits the chain of trust without a signature of its own.
  • Skip or fetch the vendor zip. After every successful place() the updater records the installed vendor hash in a marker (storage/app/private/cms-update/vendor-hash.json, stamped with the release version so a snapshot restore or /recover.php repair invalidates it). If the release's declared hash matches the marker — and the matching retained vendor zip is still on disk for rollback — the vendor download is skipped entirely: the routine update transfers ~1/3 of the bytes. Otherwise the vendor zip is downloaded (same license-key bearer, mandatory sha256 from the manifest, same ZipLimits-guarded extraction) and merged into staging.
  • Both-or-nothing: all of this happens before place() touches a single live file — a failed or mismatched vendor download aborts the update with the install untouched.

Rollback pairing. A split update retains the app-only zip as releases/v{version}.zip plus its vendor package as releases/vendor-{hash}.zip (with a checksum sidecar). Both rollback paths rebuild the prior release from that pair — never from the live vendor tree, which the failed update may have half-replaced; a retained combined zip (pre-split, or a combined-path update) rolls back exactly as before. When the pair can't be completed (vendor zip missing/tampered), rollback refuses with a clear message and the pre-update snapshot + /recover.php remain the way back.

Old updaters, install.php, and /recover.php never see any of this: the update block's download_url keeps pointing at the combined zip indefinitely, which is also what makes updating TO the first split release safe — that update still runs the old updater. 5. Place the build over the install: copy every file in, then delete the files the release's release-manifest.json marks as removed (so deletions are mirrored, not just additions). A curated set of paths is never overwritten or deleted — .env, storage/, .git/, the runtime-written routes/web.php + config/navigation.php, runtime ⚡ page blades, the custom design-library tree, and app/CustomFeatures/. The protected list always wins over the manifest, so a release can never wipe your secrets, content, or per-install customizations. 6. In-process artisan — migrate, design-library:index, db:seed ClientPageSeeder, css:supplement, and the production cache rebuild, via Artisan::call() rather than proc_open. OPcache is flushed before the artisan phase so freshly-copied classes load, and again after so the next request runs entirely new code.

Everything failure-prone (download, checksum, unzip, validate) happens before any live file changes — only step 5 modifies the install, and only after a fully-verified build exists in staging.

Honest trade-offs

  • A release is a full build, not a diff. Each zip ships vendor/ + public/build/. The vendor split claws back the biggest constant — vendor/ (~2/3 of the artifact) is skipped whenever it hasn't changed — and delta-aware place() only writes files that differ, but the download is still a build rather than a patch.
  • Rollback is a snapshot restore. Step 1 takes the snapshot for exactly this reason.
  • In-process post-install runs under the old code for that one run. Schema correctness is unaffected (the migrator is file-driven), and the next request runs entirely new code; a release that changes the seeder/indexer/migration runner itself may want a second "Redeploy" click to fully settle.

The pre-update snapshot is a hard gate by default: if it can't be created (disk full, storage not writable), the update aborts before any file changes rather than proceeding with no rollback point. Installs that manage backups externally can opt out with CMS_UPDATE_REQUIRE_SNAPSHOT=false.

Recovery when the site won't load (/recover.php)

If an update is interrupted at exactly the wrong moment (the host kills the process mid-copy), the install can be left with a mixed old/new file tree that no longer boots — and the dashboard's own update/restore UI is unreachable precisely when it's needed. Every install ships a framework-free repair page at /recover.php (raw PHP, no framework, no database — the same footing as the installer):

  1. Open https://your-site.example/recover.php. The page is locked by default.
  2. Prove you own the site: create an empty file named allow-recover.txt inside the storage folder using your host's file manager or FTP, then reload the page.
  3. Click Repair this install. The tool re-downloads the current release from the license server (using the CMS_LICENSE_KEY in .env when present — keyless works too), verifies its sha256, and re-places every application file — the same idempotent copy a fresh install runs. Content, settings, uploads, the database, and all runtime-written files are untouched by construction (the release zip only carries git-tracked files). Bootstrap caches are cleared and the flag file is deleted so the tool re-locks itself.
  4. Open the site, log in, and if the interrupted update never ran its database steps, click Settings → Updates → Update Now once to finish. Snapshots remain available under Tools → Backups.

Nothing is placed until a fully-verified download exists, so an early failure (no outbound network, bad checksum) leaves the install exactly as it was.

Host Compatibility panel

Dashboard → Tools → Host Compatibility shows, in plain language, how this server runs each feature — CMS updates (git vs package), backups (native mysqldump vs the pure-PHP PDO dumper), asset rebuilds (always the pure-PHP generator), storage media, and the database — plus a checklist of detected capabilities (proc_open, symlinks, ZipArchive, PDO, OPcache, git checkout, Composer, mysqldump, Node). It's driven by HostCapabilities, the single source of truth the update and backup engine gates read to pick the fast native path where the host allows it and a pure-PHP fallback everywhere else — so nothing ever hard-fails on a missing function. The panel is the quickest way to confirm which update engine an install will actually use.

For troubleshooting, the same data (plus configured-vs-active engine modes, resolved mysqldump/Composer paths, CMS version, and PHP limits/extensions) is mirrored under Dashboard → Error Log → PHP Info, alongside the actual error stack traces. A Copy diagnostics button there dumps the whole curated report as plain text for pasting into a support ticket — so when someone reports "backup failed," you can see in one place that they're on, say, Backups: Pure-PHP (PDO dump) with no mysqldump client. The report is built only from curated arrays (never raw phpinfo()/$_ENV), so it carries no secrets.

Producing and publishing a package release

The release artifact is a self-contained zip the consumer can install with no composer/npm. The mothership builds and publishes it in two steps (full runbook: docs/releasing.md):

php artisan cms:package-release      # build the zip
php artisan cms:publish-release storage/app/private/releases/webprocms-{version}.zip --notes="…"

cms:package-release zips the current git-tracked tree (which commits vendor/ + public/build/, so the artifact is a complete build) under a WebProCMS-{version}/ wrapper, bakes a release-manifest.json with a cumulative delete list — every path ever deleted in the repo's history (git log --diff-filter=D --no-renames), minus paths tracked again today — and prints the zip's sha256. Cumulative matters because installs jump straight from any old version to the latest: a manifest that only covered the last release would leave orphaned files on any install that skipped versions. cms:publish-release then copies that zip to a token-named static file under public/dl/, records the version / notes / download URL / sha256 in release.* Settings, and prunes the prior file. The membership/update poll (every 6 hours) hands that update block to recognized installs; UpdateChecker::ingest() records it and UpdateCmsPackageJob consumes it. cms:package-release is the only piece of the update story that uses git/proc_open — it's a release tool that runs on the mothership, never on a customer's host.


Feature updates (a separate channel for website-specific modules)

Everything above updates core CMS code via git — and that includes the built-in "Premium Features" (app/Features/*), which ship inside the repository and update with the CMS itself. They get all of git's guarantees for free: atomic merges, rollback, provenance.

Website-Specific (custom) features are different. These are per-install modules that aren't in the core repository, so they can't ride the git updater. They have their own update channel, surfaced on Dashboard → Settings → Features → Website-Specific Features. A custom module can ship a new version without any core release, and a core update never touches it.

This split is deliberate. Core features belong to the product and update with it; custom modules belong to the individual install (often bespoke work for one client) and update on their own schedule from their own source.

There is no way to pin a core feature at an older version, and that is by design. app/Features is git-tracked and deliberately absent from CmsPackageUpdater::PROTECTED_PATHS, so a release overwrites it. Protecting one module's directory would not freeze it safely either: the rest of the release moves on around it, unpinned core code calls into the pinned module (sitemap, seeder, dashboards, PresetRegistry, DesignRow gating), and its migrations run anyway — frozen code against a schema it doesn't expect is a broken install, not a frozen one. The needs that would motivate pinning are already served by three existing mechanisms: the update channel (Early / Standard / Cautious soak), rollback to the retained prior release, and the feature toggle (turn a module off without losing its data). If one client genuinely needs a module frozen on its own cadence, the supported answer is to fork it into their app/CustomFeatures/ — which is protected from updates and does have per-feature manual/auto modes.

What the operator sees

On the Premium (core) Features tab, cards show no version and no update controls at all. Core modules ship inside the CMS release and are replaced wholesale by every update, so every install on CMS vX runs byte-identical module code — the CMS version is their version, and a separate per-feature number could only ever restate it (the hand-maintained ones this replaced had drifted years stale). Versioning is a custom-module concept, because only a custom module has its own release feed to compare a version against.

On the Website-Specific Features tab, each card shows:

  • the installed version,
  • an amber "Update available → vX" badge when its release feed advertises a newer version,
  • a Update now button to apply that update on demand, and
  • a per-feature auto-update dropdown with three modes:
    • Manual updates — never auto-applies; the operator clicks Update now.
    • Automatic minor — auto-applies patch and minor bumps (e.g. 1.2.0 → 1.3.0), but a breaking major (2.0.0) still waits for a manual click.
    • Automatic major — auto-applies everything, including majors.

A Check for updates button on the Website-Specific tab polls every module's feed on demand.

How it works

  • Per-module release feed. Each module declares an update_url (https-only) in its manifest.json. The feed returns {version, notes, download_url, sha256, min_cms_version?} — the same shape as the core update block, so different modules can be served from different sources, and downloads can be gated per-client with a bearer token.
  • Daily polling. The features:check-updates artisan command (lazy-cron, daily — separate from cms:check-updates) checks each module's feed, records the latest version, and auto-applies per each module's mode.
  • Apply pipeline. Updates download the module ZIP, verify its SHA-256 checksum, extract it with the same path-traversal guards as a manual upload, and run the module's migrations. No composer install and no npm build run — modules ship their own pre-installed vendor/ directory (the same "commit the dependencies" approach core uses to keep updates fast and low-RAM on shared hosting).
  • Atomic + safe. Extraction goes to a hidden staging directory beside the module and is swapped into place with two renames; a failed download, checksum mismatch, or mid-extraction failure (unreadable entry, short write) aborts with the installed copy untouched — and if the swap-in itself fails, the previous module is restored.

Configuration

Per-module update behaviour is stored per feature (auto-update mode, recorded latest version, and an optional download token), set from the Features page — there are no global .env keys for it. A module with no update_url simply shows its version and reports "Up to date"; the update controls activate once a feed is configured.