The framework ships a self-updater that downloads a release archive, extracts it, runs composer install, executes migrations, and rolls back on failure. The bulk of it is transparent, but a few knobs matter on hosts where composer isn't on the PHP-FPM pool's PATH.
Authorization (2.8.0)
ApplicationUpdateManager performs no authorization of its own. It cannot: the five update:* commands run from the console with no authenticated user. Nothing in the framework changes that — the manager stays a plain service, and every shipped trigger stays console-gated.
What the framework does ship, as of 2.8.0, is the ability names a host application authorizes against when it wires the admin UI this module was written for:
| Constant | Ability | Covers |
|---|---|---|
UpdateCapability::PERFORM |
cms.updates.perform |
performUpdate() — the ten-step update. |
UpdateCapability::ROLLBACK |
cms.updates.rollback |
rollback() — restore a pre-update snapshot. |
UpdateCapability::VIEW |
cms.updates.view |
checkForUpdate() / updateState() — read update availability and status. |
use ArtisanPackUI\CMSFramework\Modules\Core\Updates\Managers\ApplicationUpdateManager;
use ArtisanPackUI\CMSFramework\Modules\Core\Updates\UpdateCapability;
use Illuminate\Support\Facades\Gate;
Route::post( '/admin/updates/perform', function () {
Gate::authorize( UpdateCapability::PERFORM );
app( ApplicationUpdateManager::class )->dispatchUpdate();
return response()->noContent();
} )->middleware( ['web', 'auth', 'throttle:2,60'] );
Note the endpoint dispatches and returns rather than calling performUpdate() and blocking — see Queued updates. dispatchUpdate() performs no authorization of its own either: queueing a job that will overwrite PHP files and run composer install is the same remote-code-execution channel as running it inline.
The default is deny
CoreServiceProvider registers each ability with a definition that returns false. That is deliberate. performUpdate() is by design a remote-code-execution channel — it overwrites PHP files and then runs composer install, which executes post-install-cmd scripts from the just-overwritten composer.json — so a permissive default would hand that to every authenticated user of every host that upgrades. Two paths grant it:
-
Seed an RBAC permission whose name or slug matches the ability.
PermissionsTableSeederseeds all three and assigns them to theadminrole (which receives every permission). rbac'sGate::beforehook runs ahead of every Gate definition and short-circuits it, so a seeded permission decides on its own.This beats your own definition too, not just the framework's.
Gate::beforematches an ability against thenameandslugcolumns of every row inpermissions; a hit returnshasPermissionTo()and theGate::define()below is never reached. So if you seedcms.updates.performand define a stricter rule for it, the seeded permission wins and your rule is silently bypassed. Pick one mechanism per ability: grant through roles, or define the ability and don't seed the permission. -
Define the ability in your own application. Provider boot order puts
AppServiceProvider::boot()after the framework's package providers, so your definition replaces the shipped one:Gate::define( UpdateCapability::PERFORM, fn ( $user ) => $user->hasRole( 'owner' ) );If your host registers the ability from a provider that boots before the framework's, that definition is left alone too — the framework skips any ability
Gate::has()already reports.
Gate the route, not just the ability
Authorization is necessary and not sufficient. The route wants a rate limiter and CSRF protection as well, and the UI wants the button disabled while updateState() reports queued or in_progress. A route that calls performUpdate() inline rather than dispatching also occupies a PHP-FPM worker for the whole run and keeps occupying it after the caller disconnects (see Long-running updates), which makes it a cheap way to exhaust the worker pool if the guards above are missing.
Integrity verification (fail-closed by default)
As of 2.5.4, ApplicationUpdateManager::maybeVerifyChecksum() throws UpdateException::checksumRequired() when the update source does not advertise a SHA-256 hash. This closes the "download and execute arbitrary remote code without integrity verification" gap that existed through 2.5.3.
Hosts on trusted networks or air-gapped mirrors that intentionally accept the risk can opt back into the pre-2.5.4 warn-and-continue behavior:
// config/cms.php (or via env)
'updates' => [
'allow_unverified_updates' => env('CMS_UPDATES_ALLOW_UNVERIFIED', false),
],
Those hosts still get the original warning log for every unverified update.
What the checksum does and does not protect against
Be clear about the guarantee. The digest comes from the same origin and trust
domain as the archive — a *.sha256 sidecar on the same release, a SHA-256:
line in the release description, or the same JSON document that supplied
download_url. It is therefore an integrity check: it catches truncation,
CDN corruption, and partial downloads. It is not an authenticity check. It
does not protect against a compromised update server, a compromised
release-editor account, or a plaintext-HTTP MITM. There is no signature
verification in this module.
As of 2.7.1, GitHubUpdateSource discovers checksums the same way
GitLabUpdateSource does — a {asset}.sha256 release asset first, falling back
to a SHA-256: marker in the release body. Before that it never populated
sha256 at all, so with shipped defaults every GitHub-sourced update threw
checksumRequired and the only workaround was enabling
allow_unverified_updates permanently.
Transport (2.7.1)
Release archives are downloaded over https only, and redirects are
constrained to the same scheme — an https URL that 302s to http is
refused rather than silently downgraded. This matters because the update
pipeline is by design a remote-code-execution channel: it overwrites PHP files
and then runs composer install, which executes post-install-cmd scripts from
the just-overwritten composer.json.
Air-gapped mirrors on a trusted network that genuinely cannot serve https can
opt out with cms.updates.allow_insecure_transport (env:
CMS_UPDATES_ALLOW_INSECURE_TRANSPORT). The updater logs a warning and
proceeds.
Target version and downgrades (2.7.1)
When --target-version pins a release other than the latest, the checksum is
resolved for that release rather than for the latest one. Previously the
pinned archive was compared against the latest release's digest, which failed
closed on its own but installed an unverified archive when combined with
allow_unverified_updates.
A target that is not newer than the installed version is refused, because installing an older release re-introduces every vulnerability fixed since it shipped and migrations are not reversed:
php artisan update:perform --target-version=2.6.0 # refused
php artisan update:perform --target-version=2.6.0 --allow-downgrade # explicit opt-in
Minimum-version preflight (2.8.0)
performUpdate() refuses a release the host cannot run before any file is
touched. A release advertising min_php_version / min_framework_version in
its metadata is checked against the running host up front: an unmet PHP minimum
throws UpdateException::incompatiblePhpVersion and an unmet framework minimum
throws UpdateException::incompatibleFrameworkVersion, both before extraction,
composer install, or migrations. Previously these fields were parsed and
cached but never enforced, so a release declaring min_php_version: 8.4
installed on 8.2 overwrote the tree first and only failed once the new code hit
a syntax the interpreter could not parse.
The framework-version check is skipped when the installed version is a
non-numeric dev/branch alias (dev-main, a path-repo checkout): such a build
has no meaningful ordering against a release constraint, so the updater proceeds
rather than refusing. The PHP check always runs.
Concurrent updates (2.7.1)
performUpdate() takes an exclusive flock on a sentinel beside the state file
before doing anything. A second update — a double-clicked admin button, or the
scheduled auto_update racing an operator's update:perform — is refused with
UpdateException::updateAlreadyRunning() rather than extracting over the first
one. Interleaved runs produce a tree no rollback repairs, because the second
run's backup snapshots the first run's half-applied state.
The lock is deliberately not a cache lock: step 8 runs cache:clear and
would drop it mid-run. As a second line of defence, a persisted in_progress
record whose recorded PID is still alive also blocks a new run. A stale marker
from a kill -9'd run does not wedge the updater — liveness is checked, not
just presence.
Custom JSON feed authentication (2.12.0)
CustomJsonUpdateSource::setAuthentication() accepts three shapes:
| Credentials | Sent as |
|---|---|
'secret' |
The ?token=secret query parameter. |
['api_key' => '…', 'license' => '…'] |
Query parameters, verbatim. |
['headers' => ['Authorization' => 'Bearer …']] |
Request headers. An optional query key carries query parameters alongside them. |
Header mode is opt-in: a string still means ?token=, so existing feeds are
unaffected. Prefer it for anything secret — a query-string credential ends up in
URLs, access logs and proxy logs.
$checker = UpdateCheckerFactory::buildUpdateChecker( $feedUrl, UpdateType::Application, 'application' );
$checker->setAuthentication( [
'headers' => [ 'Authorization' => 'Bearer ' . config( 'services.licensing.key' ) ],
] );
The same headers authenticate the archive download, so a private feed no longer
has to hand out a pre-signed or public download_url. They are scoped to the
feed's own origin — scheme, host and port. The feed response chooses
download_url, and a redirect can name any host after that, so the headers are
dropped from the download request, and from every redirect hop, that leaves the
feed's origin. A feed that serves its archive from a CDN or object store on
another host must keep issuing a pre-signed URL for it. Query-string credentials
are never sent to download_url.
The feed request is held to the same standard. With credential headers configured:
- the feed URL must be
https— a plaintext feed is refused before anything is sent, unlesscms.updates.allow_insecure_transportis on; - redirects are not followed. The HTTP client strips
Authorizationon a cross-origin redirect but forwards custom header names, so a redirecting feed fails with a message saying so rather than passing anX-License-Keyalong. Point the feed URL at its final location.
Feeds without credential headers — public ones, and those using query-string credentials — follow redirects as before.
Each setAuthentication() call replaces the header credential: passing a
string or a flat array afterwards clears any headers set earlier.
Transport errors are reported with the query string and any user:pass@
userinfo of the request URL redacted, so a ?token= credential does not reach
logs or scheduler output through a connection failure.
Plugins and themes opt in per slug through updateTokens — see
plugin-authoring and themes/Updating.
Metadata request path
The feed check, single-release lookup, SHA-256 sidecar fetch, and custom JSON endpoint are issued through the internal MetadataClient — a raw GuzzleHttp\Client that bypasses Laravel's HTTP factory. This is deliberate: any userland RequestSending/ResponseReceived listener (Herd Pro's HttpClientWatcher, Telescope, Debugbar, custom monitoring) can block or corrupt the metadata request lifecycle, and a wedged Herd dump-server socket used to hang the updater until max_execution_time instead of the ~200ms round-trip.
Retry semantics for the custom JSON endpoint (5xx retry, config-driven attempt count) are preserved.
The download path still uses Http::sink() — it's shielded by a withResponseMiddleware that closes the sink body before Laravel dispatches ResponseReceived, so a listener calling body() gets an empty string instead of pulling the archive into memory.
Composer discovery
ApplicationUpdateManager::runComposerInstall() resolves the composer command in this order:
-
COMPOSER_BINARYenvironment variable. Absolute path to composer. When set, the framework builds the command as{PHP_BINARY} {binary} install --no-dev --no-interaction --optimize-autoloader, so PHP-FPM'sPATHnever has to resolve composer's#!/usr/bin/env phpshebang. -
cms.updates.composer_install_commandconfig value, when it differs from the shipped default. Full shell string — set this when you need extra flags, a prependedPATH, or a bespoke composer wrapper. Backwards-compatible escape hatch for hosts that already carry a custom command. -
Auto-discovery across common install paths, first hit wins:
~/Library/Application Support/Herd/bin/composer/usr/local/bin/composer/opt/homebrew/bin/composer~/.composer/vendor/bin/composer~/.config/composer/vendor/bin/composer/usr/bin/composer
Laravel Herd's bundled composer leads the list for the same reason the CLI PHP list leads with Herd's
php: a host that uses Herd for both FPM and CLI stays on a single toolchain. Herd's composer is a#!/usr/bin/env phpscript rather than a standalone binary, which is fine — the command is built as{CLI PHP} {binary} install ...regardless. Both lists derive the Herdbin/directory from oneherdBinPath()helper so they can't drift apart.When discovery fails, the framework logs a structured
Log::warningwith theis_file()/is_executable()result for each candidate path, the currentphp_sapi, and a pointer at the fix. When a resolved binary later fails to verify (the rollback probe below), the raisedUpdateException—configuredComposerBinaryMissingfor a configured path PHP cannot see, otherwisecomposerVerificationFailedwith the resolved binary, PHP interpreter, exit code, and captured output — carries enough detail that operators can distinguish a wrong-path failure from a PHP-FPM sandboxed-stat failure (macOS Herd Pro sandboxes/opt/homebrew/*; chrooted FPM pools and restrictiveopen_basedirbehave the same way) without digging through the log. -
Bare
composer install ...— the pre-2.5.3 behavior, kept as a final fallback.
PHP interpreter used to invoke composer
Under PHP-FPM, PHP_BINARY points at the FPM daemon binary, which prints usage and exits 64 when handed the composer PHAR. Prior to 2.5.4 that produced the misleading "Composer install failed. Output: ." symptom on Herd hosts — composer was discovered fine, but the interpreter used to invoke it was the wrong SAPI.
resolvePhpBinary() now walks CLI-shaped candidates and rejects anything whose basename smells like -fpm/-cgi. Resolution order:
CMS_PHP_BINARYenvironment variable (absolute path). Set this when you need to pin a specific PHP CLI binary — a Herd version, a Homebrew-managed 8.4, a custom build.PHP_BINARYwhen the current SAPI is CLI. Short-circuit for artisan/console callers.- Auto-discovery across common CLI PHP locations (Herd, Homebrew, system).
The resolved CLI PHP is used to build both the install command and the rollback --version pre-check. When the pre-check fails, UpdateException::composerVerificationFailed() surfaces the resolved binary, PHP interpreter, exit code, and captured output — so future misdiagnoses don't repeat.
When you need the escape hatch
Laravel Herd's PHP-FPM pool ships a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) that doesn't include Homebrew or Herd's own composer. Auto-discovery covers both ~/Library/Application Support/Herd/bin/composer and /opt/homebrew/bin/composer, so a stock Herd or Homebrew install needs no configuration; if your composer lives elsewhere, either point COMPOSER_BINARY at it or set a full command:
// AppServiceProvider::boot()
config()->set(
'cms.updates.composer_install_command',
'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin '
. '/opt/homebrew/bin/composer install --no-dev --no-interaction --optimize-autoloader',
);
Rollback error surfacing
When an update fails, the framework rolls back to the pre-update backup. Two behaviors matter for diagnostics:
-
Before invoking rollback's
composer install, the framework runs{binary} --versionwith a 10-second timeout (verifyComposerBinaryAvailable()). If that fails, you get a specificUpdateException—configuredComposerBinaryMissingorcomposerVerificationFailed, as detailed below — naming the path inspected and the relevantCOMPOSER_BINARY/CMS_PHP_BINARYoverride, not the vague "Manual intervention required". -
When that probe fails, the diagnosis depends on whether PHP can see the binary on disk. If
is_file()also reports false, you getUpdateException::configuredComposerBinaryMissingnaming the configured path — such a path can only have come fromCOMPOSER_BINARY/cms.updates.composer_binary, since auto-discovery only ever returns a path it has already stat'd. Otherwise you getcomposerVerificationFailedas before, with its exit code, captured output, andCMS_PHP_BINARYhint. Previously every failure took the second form, blaming the PHP interpreter on hosts where the interpreter had resolved correctly and the composer path was the sole fault.The stat runs after the probe, never as a pre-flight gate — deliberately. A path PHP cannot
stat()may still be perfectly reachable by the shelled-out child under PHP-FPM sandboxing, and pointingCOMPOSER_BINARYat such a path is the documented workaround for exactly that case (see the discovery section above). Gating the probe onis_file()would close that escape hatch. Once the probe has failed too, the path is overwhelmingly just wrong. -
When rollback itself fails, the resulting exception preserves the original update-failure message. You'll see both
Rollback failed: {rollback error}. Original update error: {original error}. Manual intervention required.— no more losing the actual first-error text. -
A failure before extraction does not attempt a rollback (2.11.0). The steps before
Extract— maintenance mode, backup, download, and checksum verification — never write to the application tree, sohandleUpdateFailure()skips the snapshot restore, marks rollback as not applicable (rolled_backisnull), and surfaces the original error. A benign refusal such as a missing checksum is no longer reported asRollback failed … Manual intervention required.
Extraction safety
ApplicationUpdateManager::extractUpdate() streams each ZIP entry via fopen()/fwrite() (rather than ZipArchive::extractTo()) so a single large file can't OOM mid-extraction. Two guards keep that path safe:
- Zip-slip protection. Entries whose normalized path starts with
/or contains..segments are rejected before the target directory is created. After path assembly,realpath()verifies the resolved parent still sits under the extraction root before opening the write stream. Rejected entries are logged and skipped — a craftedrelease-root/../../../etc/cron.d/xcan no longer escape the install root. - Path canonicalization (2.7.1). Entry names are canonicalized — split on
/, empty and.segments dropped, any..segment rejected — before both the exclusion check and the containment check. Without this, an entry named./.envwas simply a different string from.envand slipped pastexclude_from_updateentirely, whilerealpath( dirname( '/base/./.env' ) )resolves to/baseso the containment check passed too. The reachable targets were the ones the exclusion list is believed to protect:.env,vendor/,bootstrap/cache/*.php, and the SQLite database. - Exclusion matches on path-segment boundaries (2.7.1).
isPathExcluded()used a bare string prefix, sostorage-helpers.phpmatchedstorageand was skipped by both the backup and the extraction — neither snapshotted nor ever updated. - fopen/fread failures surface as rollback triggers. A failed
fopen('wb')orfread()used tocontinue/breaksilently, leaving a partial install on disk that failed to boot on the next request with no obvious cause. Failures are now logged with the entry + errno and thrown viaUpdateException::extractionEntryFailed(), soperformUpdate()'s catch block rolls back to the pre-update snapshot. - Short writes and close failures too (2.7.1).
fwrite()'s return value andfclose()'s are both checked. A disk filling mid-extraction previously produced a short write with no exception: the entry counted as extracted and the update proceeded intocomposer installand migrations over truncated PHP files. The partial file is removed before the exception propagates. - Symlinks and permissions (2.7.1). A target that is an existing symlink (or any non-regular file) is skipped rather than followed and truncated — relevant on Envoyer/Forge/Deployer layouts where
storageorbootstrap/cacheare symlinked to shared directories. Directory entries are validated beforemkdir -pruns rather than after. Archive-supplied permissions are clamped with& ~0022, so a0777.phpfile cannot land under the docroot writable by a neighbouring tenant.
Removing files a release deleted upstream (2.10.0)
Extraction adds and overwrites but can never delete, so a release that removes a file upstream — a dead app/Http/Controllers/*.php left beside its new Modules/* replacement after a restructure — would otherwise orphan the old file on the host, where the autoloader can still find and load the stale class. A release declares those deletions in a removed-paths manifest it ships in the archive (default .artisanpack/removed-paths.json, a JSON array of repo-relative paths); applyRemovedPathsManifest() applies them after a successful update.
- Every deletion is guarded like an extraction write. Each path is canonicalized (an absolute path or a
..traversal is rejected), skipped whenexclude_from_updateprotects it, never followed through a symlink, and re-checked for containment underbase_path()before removal. - Critical host files can never be removed. Even when a manifest names them,
.env,.htaccess,artisan,composer.json/composer.lock,public/index.php(and its.htaccess), and anything under.git/are protected — defence-in-depth over the containment and exclusion guards, since a framework release has no business deleting the front controller or environment file. - The manifest is consumed after it applies. Because extraction is add/overwrite-only, a manifest left in place would survive into later updates; a subsequent release that ships no manifest of its own would then re-apply the stale list and delete files that release deliberately re-introduced. Deleting the manifest after it runs closes that data-loss window — a real deletion set only ever arrives inside the archive being extracted.
- Best-effort, and opt-out. An absent or malformed manifest is a no-op, and the whole pass is wrapped so it can never fail a completed update. Set
cms.updates.honor_removed_paths_manifesttofalseto skip it, orcms.updates.removed_paths_manifestto relocate the file.
Purging orphaned build assets (2.10.0)
Content-hashed Vite bundles under public/build accumulate across releases — extraction adds the new hashed files but never removes the superseded ones. After a successful update, purgeStaleBuildAssets() deletes any file under the build directory (default public/build, config cms.updates.build_path) that the current manifest.json (or its .vite/manifest.json sidecar) does not reference, then prunes the directories it empties. The manifest itself is never a purge candidate, symlinks are never followed, and a missing or unparseable manifest purges nothing rather than guessing. Disable it with cms.updates.purge_stale_build_assets = false.
Rollback safety (2.7.1)
exclude_from_updateapplies on restore. Rollback previously restored every entry in the backup ZIP with no filter, so an archive carrying.envorvendor/autoload.phpreplaced the live copies — and thecomposer installthat follows executes scripts from the restoredcomposer.json.- Backups outside the configured directory are refused.
update:rollbackwith no argument picks the newestbackup-*.zipby mtime. Any other vulnerability yielding a file write understorage/would otherwise let an attacker plant a backup and wait for a restore. Pass--allow-externalto restore from a path outsidecms.updates.backup_path. - Rollback is skipped once the update is fully applied. A failure at steps 8-10 (
cache:clear, cleanup,up) happens after the code and schema are updated, so restoring the snapshot would discard a working install and leave old code against a new schema. Those failures log and point atupdate:status, which prints the commands to finish forward. - A failed rollback is reported as such. The
Failedstatus label used to readFailed (rolled back)unconditionally, so an operator facing the most dangerous state this updater produces was told the tree had been restored. A separaterolled_backfield now recordstrue/false/null(not attempted), andupdate:statusrenders each distinctly.
Dependency installation: composer.lock ships with the release
Releases must ship a committed composer.lock that matches their composer.json. The updater extracts both and runs composer install against the pair, so the host installs the exact dependency set the release was built and tested against rather than re-resolving the tree on production hardware at update time.
composer.lock is therefore not in the exclude_from_update default. It used to be, annotated // Rebuilt via composer install — but composer install only ever reads a lock file. It never writes one; only composer update / composer require do. Excluding the lock while letting composer.json be overwritten left the two out of sync and aborted step 6 on every release that changed any dependency constraint, on every host. Releases that changed no dependencies still worked, which is why it went unnoticed until the first constraint bump.
For the auto_archive GitLab strategy this is free — the archive is the repository tree, and an application should commit its lock. For release_asset, whoever builds the ZIP must include the lock.
The pre-update snapshot includes composer.lock for the same reason, so a rollback restores the old composer.json and old lock together.
Lock-sync diagnostic
Before invoking composer, the framework compares the on-disk composer.lock's content-hash against a hash computed from composer.json using composer's own algorithm, to detect whether the two have diverged. This is a diagnostic, not a gate: it does not abort the update.
composer install installs from the lock even when the content-hash is stale — it only emits a warning — and hard-fails solely when the lock cannot satisfy composer.json (a required package missing from the lock, or a constraint it violates). An earlier version (2.7.1, #255) aborted step 6 and triggered a full rollback on any divergence, which turned a release that shipped an old-but-satisfying lock into a failed update composer would have installed cleanly (#264). It no longer pre-empts composer.
What it keeps is the diagnosis. When composer itself fails over a genuinely unsatisfiable lock, its own message is actively misleading:
- Required package "artisanpack-ui/cms-framework" is in the lock file as "2.5.4" but that
does not satisfy your constraint "^2.7.0". This usually happens when composer files are
incorrectly merged or the composer.json file is manually edited.
Nothing was merged and nothing was hand-edited — but that sentence sends the operator hunting for both. The remaining ways to reach this state are a release that shipped no lock, or a host whose exclude_from_update override still lists composer.lock. When a divergence was detected, UpdateException::composerInstallFailed wraps composer's own output with that accurate explanation — but only after composer, not the framework, has decided the update cannot proceed.
The check fails open: a missing composer.json, a missing or unparseable composer.lock, or a lock carrying no content-hash records no divergence and is left for composer to adjudicate. Set cms.updates.verify_composer_lock_sync to false (env CMS_UPDATES_VERIFY_LOCK_SYNC) to skip it entirely — for instance if a future composer release changes the content-hash algorithm before the framework catches up.
Stale-lock recovery (2.8.0)
The lock-sync fix above has a reachability gap of its own (#273): it ships inside an update, and the broken lock it fixes is exactly what aborts that update. An install still on an affected line (≤ 2.7.0) therefore has no updater-reachable path to the fix — the population that needs it is precisely the population that cannot receive it. Recovery otherwise needs a shell on the server, which for a click-to-update product is the wrong failure mode.
So when composer install aborts because the extracted composer.json requires a dependency set the still-in-place previous release's composer.lock cannot satisfy, the updater does not immediately roll back. It parses the packages composer named as unsatisfiable and runs a targeted composer update <those packages> --with-all-dependencies, re-resolving the flagged packages and their dependency closure (everything outside it stays pinned at the lock) so the resulting lock satisfies composer.json and the update proceeds. --with-all-dependencies is what lets a flagged package's new version pull the transitive bumps it needs; without it composer would leave those pinned and fail to resolve the very case recovery exists to unstick.
Every guard fails toward the original safe rollback. The recovery runs only after composer itself has failed on an unsatisfiable lock — never pre-emptively — and only when composer named at least one package and composer_install_command is not an operator override that cannot be rewritten into an update. If the recovery composer update also fails, the update rolls back exactly as before.
Recovery keys off composer's own "Required package" diagnosis, not the content-hash check above, so it is independent of verify_composer_lock_sync: disabling that hash check (only enriches the failure message) does not disable recovery. That is deliberate — the documented reason to disable the hash check is a future composer that changes the hash algorithm, which is exactly when a stale lock is most likely and recovery most wanted. cms.updates.recover_stale_lock is the only switch for recovery.
This does mean the recovered packages land on a freshly resolved version rather than the one the release's lock pinned — the documented trade-off from unexcluding the lock, but scoped here to only the packages composer flagged. A host that treats the tested dependency set as inviolable, and would rather fail loudly than re-resolve any package on production at update time, sets cms.updates.recover_stale_lock to false (env CMS_UPDATES_RECOVER_STALE_LOCK) to keep the pre-#273 behavior.
Recovering an install by hand. Recovery is automatic on 2.8.0+, but an install arriving at 2.8.0 from an affected line first has to run the update that carries it — and that first update is the one the stale lock can block, before 2.8.0's recovery code is in place to catch it. If the in-app updater aborts on a message like the one above, run this once on the host and then retry the update:
composer update artisanpack-ui/cms-framework --with-all-dependencies
On many hosts this resolves cleanly on its own, because a constraint like ^2.5.3 already admits 2.7.1+ — the lock was merely stale, not incompatible. --with-all-dependencies matches what the automatic recovery runs and lets any transitive bump the new version needs come along; drop it if you want to hold every other package at its locked version. After the install is on 2.8.0+, recover_stale_lock handles subsequent updates without the manual step.
Long-running updates and interrupted processes
A full update — download, extract, composer install across a real dependency tree, migrate — routinely runs for several minutes. PHP's max_execution_time defaults to 30 seconds under PHP-FPM, which is the path the admin UI uses. Three guards keep that survivable:
-
performUpdate()androllback()callset_time_limit( 0 )andignore_user_abort( true )up front. TheProcess::timeout()inrunComposerInstall()only bounds the composer child; without this the parent request was killed roughly twenty times sooner than composer's own budget allowed for.ignore_user_abort()covers the operator closing the browser tab. When a host hasset_time_limitindisable_functions, the framework logs a warning namingphp artisan update:performas the supported path instead. -
Maintenance mode is lifted by a shutdown handler when the process dies anyway. An execution-time or out-of-memory fatal is raised at shutdown, not thrown, so
performUpdate()'scatchnever runs — no rollback, and step 10 (disableMaintenanceMode()) never executes. The site then serves 503 to every visitor with no error in the UI and no automatic way back.enableMaintenanceMode()now registers a shutdown guard that lifts maintenance mode in that case and logs acriticalentry naming the step it died on. Ifartisan upitself fails (common when shutting down after an OOM fatal), the guard removesstorage/framework/downdirectly.Leaving the site up on a possibly half-applied install is a real trade-off, so
cms.updates.lift_maintenance_on_interruptis step-aware by default ('step_aware'). The guard lifts maintenance mode for deaths in steps 1-4 (nothing has touched the tree) and steps 8-10 (the code and schema are fully applied), but keeps the site down for deaths in steps 5-7 — extract, composer install, migrations — where a half-extracted tree or half-run migration set would otherwise go back on the public internet and could silently disable authorization. Set the config totrueto always lift regardless of step (the pre-2.8 behaviour), orfalseto fail closed and keep the site down after any interruption until an operator has verified it by hand.This cannot help against
kill -9, which runs no shutdown handlers. The persisted state marker below covers that case. -
Each step is persisted to a state file, so a killed update is diagnosable. Before this there was no way to distinguish "update in progress", "update died at step 6", and "the site was manually put into maintenance mode".
php artisan update:status
Reports the most recent run — the step it reached, the versions involved, the recorded error, and, for an interrupted run, the outstanding steps with the command for each:
$ php artisan update:status
✗ Interrupted (process died mid-update)
From version 0.2.4
To version 0.3.0
Last step 7/10 — Run database migrations
...
The update process died before finishing. The install may be half-applied.
These steps had not completed. Run them in order to finish the update:
7. Run database migrations
php artisan migrate --force
8. Clear application caches
php artisan config:clear && php artisan cache:clear && php artisan route:clear && php artisan view:clear
10. Disable maintenance mode
php artisan up
It exits non-zero when the last run failed or was interrupted, so it composes with health checks — in both output modes, so php artisan update:status --json || alert is a valid monitoring idiom. (Before 2.7.1 the --json branch returned success unconditionally and never alerted on a dead update.) --json emits the raw record for an admin UI; --clear discards it after reporting. Host applications can read the same record programmatically via ApplicationUpdateManager::updateState().
A run interrupted during download or extraction is not resumable — the application tree may be partially overwritten — so the command points at the pre-update snapshot in storage/backups/application/ instead of a resume checklist.
The marker is a flat JSON file rather than a cache entry on purpose: step 8 runs cache:clear, and the database cache driver is unavailable while step 7's migrations are mid-flight. storage/ is in exclude_from_update, so it survives extraction of the new release.
OPcache and compiled caches after an update (2.15.6)
With opcache.validate_timestamps=0 (Laravel Forge's OPcache optimisation, and common in production), PHP-FPM never re-reads a file it has already compiled. Before 2.15.6 the updater cleared Laravel's caches but not OPcache, so the site ran a mix of old and new code until PHP-FPM was reloaded. A route cache built afterwards could name middleware the stale provider never registered, and every page returned 500.
The updater now calls opcache_reset() once the files on disk are final: after a successful update (after step 9's cleanup, before maintenance mode is lifted), after a rollback, and after a failure that leaves new or half-applied code in place.
- From a PHP-FPM request (the admin UI calling
performUpdate()), the reset clears the pool's shared cache and nothing more is needed. - From the CLI or a queue worker (
update:perform,update:rollback,dispatchUpdate()),opcache_reset()only clears that process's own cache. Since 2.16 the updater then resets PHP-FPM's cache another way, described in Resetting PHP-FPM's OPcache from the CLI below. - If no reset reaches the web server (from the CLI, or because
opcache.restrict_apiblocks it), the updater logs a warning, recordsphp_reload_required: truein the update state, andupdate:perform,update:rollbackandupdate:statustell the operator to reload PHP-FPM. ApplicationUpdateManager::phpReloadRequired()returns the same flag for the last update or rollback that instance ran, andopcacheResetMethod()returns how the reset happened, for an admin UI that wants to show it.
Resetting PHP-FPM's OPcache from the CLI (2.16.0)
The CLI is the recommended way to run an update, so a CLI update should not leave FPM on stale bytecode until someone remembers to reload it. After a CLI or queued run, the updater tries these in order and stops at the first that works:
- FastCGI to the FPM socket (
fastcgi), the approach cachetool uses. The updater writes a one-off script tostorage/framework/with a random name, has FPM run it over FastCGI, and deletes it. The script is standalone, so it works even when the stale bytecode would stop the application from booting. SetCMS_UPDATES_FPM_SOCKETto the pool'slistenaddress (/run/php/php8.4-fpm.sock,unix:///…, or127.0.0.1:9000). If it isn't set, the updater tries Forge's isolated-site socket (/run/php/php{version}-fpm-{user}.sock), the Debian/Ubuntu default (/run/php/php{version}-fpm.sock) and the RHEL default (/run/php-fpm/www.sock), using the CLI's PHP version. Ifopcache.restrict_apiis set, it must allow scripts understorage/framework/. - Signed loopback request (
loopback). The updater requests/cms-framework/updates/opcache-resetonCMS_UPDATES_OPCACHE_LOOPBACK_URL(defaultAPP_URL) with a relative signature that is valid for one minute, and the route callsopcache_reset()inside FPM. The route is exempt from maintenance mode, because the updater calls it before bringing the site back up, and it can do nothing except reset OPcache. Redirects are followed only to the same host and port, or fromhttpon port 80 tohttpson 443, so the signed URL is never sent to another site or to another application on the same host. SetCMS_UPDATES_OPCACHE_LOOPBACK=falseto turn this off and unregister the route. - Operator command (
command).CMS_UPDATES_PHP_RELOAD_COMMAND, for examplesudo -S service php8.4-fpm reload, runs frombase_path(). It is empty by default. If it fails, the log records its exit code and output but not the command itself, in case it carries a password forsudo -S.
OPcache's shared memory belongs to the FPM master process, so resetting it through any pool resets it for every pool. The mechanism that worked is recorded as opcache_reset_method in the update state (web-request when the update itself ran under FPM), and update:status shows it as OPcache reset. update:perform and update:rollback print it too. If all three fail, the warning described above appears in the log, with the reason each mechanism failed, and in the console output. CMS_UPDATES_OPCACHE_FASTCGI=false turns off the first mechanism, and CMS_UPDATES_OPCACHE_TIMEOUT (default 5 seconds) bounds the FastCGI and loopback requests.
The installation has to be running 2.16 before an update starts for this to apply, because the updating process runs the updater code that was installed before the update.
The compiled caches that were in place before the update are also rebuilt. Step 8 notes which of the config, route and event caches exist before clearing them, and after step 9 the updater runs config:cache, route:cache and event:cache for those, and only those, so a production install no longer drops to uncached config and routes. Each command runs in a fresh PHP process (the same interpreter used for composer, see PHP interpreter used to invoke composer), so the cache is compiled from the new code rather than from the classes the updating process still holds in memory. A failed rebuild is logged with the command to run by hand and does not fail the update.
Note: the guards above make the HTTP path safe to fail; they don't make it a good place to run a multi-minute job. Run updates from the CLI with
php artisan update:perform, wheremax_execution_timeis0and there is no gateway timeout to hit — or, when the trigger has to be an HTTP request, dispatch to a queue worker withdispatchUpdate().
Keep performUpdate() behind admin authorization
Gate::authorize( UpdateCapability::PERFORM ), per Authorization. Lifting max_execution_time and setting ignore_user_abort( true ) means an inline HTTP-triggered update occupies a PHP-FPM worker for as long as the update takes, and keeps occupying it even if the caller disconnects. The individual phases stay bounded (download_timeout, composer_timeout), so the total is bounded in practice — but an update endpoint reachable without authorization would be a much cheaper way to exhaust the worker pool than it was at 30 seconds. This has always been an operator responsibility; the guards raise the cost of getting it wrong.
Queued updates (2.8.0)
The guards above make an HTTP-triggered update survivable. They do not make it appropriate. An update run inline from a POST handler still:
- occupies a PHP-FPM worker for the full run, and keeps occupying it after the caller disconnects;
- is subject to gateway timeouts — nginx's
proxy_read_timeout, load balancers, Cloudflare's 100s — that no userland call can override; - is subject to FPM's
request_terminate_timeout, whichset_time_limit()cannot override; - gives the operator no feedback beyond polling the state file.
ApplicationUpdateManager::dispatchUpdate() is the supported answer. It pushes a PerformUpdateJob onto a queue and returns immediately; the endpoint polls updateState() for progress:
$manager = app( ApplicationUpdateManager::class );
Gate::authorize( UpdateCapability::PERFORM );
$manager->dispatchUpdate(); // latest
$manager->dispatchUpdate( '2.9.0' ); // pinned
$manager->dispatchUpdate( '2.6.0', true ); // pinned, downgrade opted in
$manager->updateState(); // poll this — status goes queued → in_progress → completed/failed
php artisan update:perform --queue does the same thing from the console, after the usual confirmation prompt.
performUpdate() is unchanged and still supported as a direct call. Integrators calling it inline are unaffected by this job existing; the job simply calls it on a worker instead of in a request.
The sync driver is refused, not tolerated
Dispatching to the sync driver executes the job inline in the dispatching process — the exact multi-minute blocking request queueing exists to avoid, reintroduced silently while the feature looks from the outside like it works. dispatchUpdate() therefore inspects the resolved connection's driver first and throws UpdateException::updateQueueUnusable() rather than degrading:
| Driver | Behavior |
|---|---|
Any real driver (database, redis, sqs, …) |
Dispatched. |
sync |
Refused, unless cms.updates.queue.allow_sync is true — then it warns and proceeds. |
null |
Refused. It discards every job it is given, so the update would never run. allow_sync does not rescue it. |
| Unconfigured connection | Refused, naming cms.updates.queue.connection. |
What the guard cannot detect is a perfectly well-configured connection with no worker consuming it — that is not knowable from config. That case shows up as a record stuck at queued, which update:status names explicitly and answers with the queue:work invocation to run.
Raise retry_after — this is the one you have to set by hand
The job's own $timeout defaults to download_timeout + composer_timeout + a 900s buffer for the steps that have no timeout of their own (backup, extraction, migrations) — 1,800s with the shipped values. Override it with cms.updates.queue.timeout.
That timeout travels with the job and takes precedence over the worker's --timeout: Worker::timeoutForJob() is $job->timeout() ?? $options->timeout, so the worker flag is only a fallback for jobs that carry no timeout. A worker started with a short --timeout will not cut an update short.
retry_after is the setting that will bite you. It is the queue's "this reserved job must have died, hand it to someone else" timer, and Laravel ships 90 seconds for the database, redis and beanstalkd connections — far shorter than any real update. Left alone, the queue redelivers the update 90 seconds in while the first worker is still running composer install.
$tries = 1 does not save you from that; it is what makes it land badly. The duplicate exceeds max attempts, so the worker fails it without ever calling handle() — it goes straight to failed() carrying a MaxAttemptsExceededException against a run that is perfectly healthy.
So dispatchUpdate() refuses to dispatch when retry_after is not greater than the job timeout, with UpdateException::updateQueueRetryTooShort() naming both numbers:
// config/queue.php
'database' => [
'driver' => 'database',
'retry_after' => 1900, // above cms.updates.queue.timeout
],
php artisan queue:work --queue=updates --tries=1
Only connections that actually carry the setting are checked; SQS expresses the same idea as a queue-side visibility timeout that is not readable from the app.
Concurrency, and what happens when a queued update dies
- A second dispatch is refused loudly.
dispatchUpdate()takes the job's unique lock itself rather than leaving it toPerformUpdateJob::dispatch(), which takes the same lock and then silently returns when it cannot get it — the caller would be told the update was queued and nothing would ever run it. A double-clicked admin button getsUpdateException::updateAlreadyQueued()instead. - A
queuedrecord stops blocking once it is older than the job timeout, so a host that dispatched before starting a worker is not wedged by its own first attempt. $triesis 1 and not configurable. A retry would restart the update at step 1 over a tree the previous attempt had already partly overwritten — extracting a second release over a half-extracted first one, which is the interleaving theflocksentinel exists to prevent, reintroduced by the queue rather than by a concurrent caller.- The
flocksentinel is still the real guarantee. The job'sShouldBeUniquelock lives in the cache, and step 8 of the update runscache:clear— so it is dropped near the end of every successful run. The concurrency guard insideperformUpdate()is unaffected by any cache flush. - A killed job does not leave the site down.
PerformUpdateJob::failed()reconciles the persisted record: a run killed by the worker timeout is stampedinterruptedand maintenance mode is lifted according tocms.updates.lift_maintenance_on_interrupt— including the step-aware default, which keeps the site down when the job died in a half-applied step (5-7) — and a job that failed before the update started is stampedfailedrather than left claimingqueuedforever. A run that already recorded its own outcome is left alone, so the real error is never replaced by the worker's generic one. - Reconciliation only ever touches its own run. A failing job can be handed a record it does not own — losing the
flockrace throwsupdateAlreadyRunningbeforeperformUpdate()'s bookkeeping starts, and aretry_afterredelivery arrives having done no work at all. Both would otherwise mark a healthy in-flight runinterruptedand runartisan upon a site that is mid-extraction.failed()checks the recorded PID for liveness first and leaves other processes' runs alone. update:status --clearreally resets. The record lives instorage/and the dispatch lock lives in the cache, so they can desynchronise. Clearing the record releases the lock too — otherwise clearing a stuckqueuedrecord left dispatch refusing with "an update is already queued" whileupdate:statusreported that none had ever been recorded, recoverable only bycache:clearor by waiting out the TTL.
Wiring the admin UI
Dispatch, then poll updateState(). The status field moves through:
| Status | Meaning |
|---|---|
queued |
On the queue, no worker has claimed it. Nothing on the installation has changed. |
in_progress |
Running. step / step_number / step_label name the step in flight. |
completed |
Finished. |
failed |
The updater caught the error; rolled_back says what became of the snapshot. |
interrupted |
The process died before the catch block could run. |
A run dispatched through the queue also carries queued_at, queue_connection and queue_name, and keeps them for the whole run — "which worker is meant to be running this" stays the operative question until it finishes. Disable the update button while status is queued or in_progress.
Cached update info and out-of-band version bumps
UpdateChecker::checkForUpdate() caches the resolved UpdateInfo value object under cms.{type}.{slug}.update_check for cms.updates.cache_ttl seconds (default 43,200s / 12h). For the application updater, {slug} is cms.updates.application_slug (default application), so changing that slug keys the cache under a new entry and costs one fresh check. The cached object contains both the feed's latestVersion and a snapshot of the installed version taken when the cache was populated — read from cms.updates.current_version_config_key (default app.version), which UpdateInfo::resolveCurrentVersion() honours as of 2.8.0.
When the host's installed version changes out-of-band (a manual composer install on a release zip, an unzip-over-site, a deploy script) the framework keeps the cached feed data honest in two ways:
UpdateChecker::checkForUpdate()discards the cachedUpdateInfowhen the cachedcurrentVersionsnapshot no longer matches the installed version (read fromcms.updates.current_version_config_key, defaultapp.version) and re-fetches from the source. No stale positive for up to 12h.UpdateInfo::hasUpdate()reads the installed version at call time — viaresolveCurrentVersion(), which honourscms.updates.current_version_config_key(defaultapp.version) — rather than comparing against its own frozencurrentVersion, so any cached instance still returns the correct answer even if the invalidation branch above hasn't fired yet. Falls back to the constructor value when the container has no boundconfig.
Related config
| Key | Purpose |
|---|---|
cms.updates.application_slug |
Identifies the application to the update source and keys its update-check cache (cms.application.{slug}.update_check). Default 'application', env UPDATE_APPLICATION_SLUG. Replaces the old hard-coded digital-shopfront-cms slug; changing it invalidates the previous slug's cache entry, costing one fresh check. |
cms.updates.current_version_config_key |
Config key the updater reads the installed version from. Default 'app.version'. As of 2.8.0 this is actually honoured by UpdateInfo::resolveCurrentVersion() — a host that stores its version elsewhere points this at that key. |
cms.updates.check_frequency |
Advisory cadence string (default 'daily', env UPDATE_CHECK_FREQUENCY). The framework registers the update:check-scheduled command but does not schedule it — the host must wire it into its own scheduler, e.g. Schedule::command('update:check-scheduled')->daily() in routes/console.php. |
cms.updates.composer_binary |
Absolute path to composer; priority-1 override, populated from env('COMPOSER_BINARY'). |
cms.updates.composer_install_command |
Full command; overrides discovery when non-default. |
cms.updates.composer_timeout |
Seconds to wait for composer install (default 600). |
cms.updates.allow_unverified_updates |
Opt-in to warn-and-continue when the source omits a SHA-256 checksum. Default false. Env: CMS_UPDATES_ALLOW_UNVERIFIED. |
cms.updates.backup_enabled |
Whether to snapshot before updating. |
cms.updates.exclude_from_update |
Paths preserved during extraction. Must not list composer.lock — see above. |
cms.updates.verify_composer_lock_sync |
Whether to check composer.json/composer.lock agreement before invoking composer. Default true. Env: CMS_UPDATES_VERIFY_LOCK_SYNC. |
cms.updates.recover_stale_lock |
Whether a composer install that aborts on a stale previous-release lock triggers a targeted composer update of the flagged packages instead of rolling back. Default true. Env: CMS_UPDATES_RECOVER_STALE_LOCK. |
cms.updates.state_path |
Where the step marker is written. Relative paths resolve against storage_path(). Default framework/cms-update-state.json. |
cms.updates.lift_maintenance_on_interrupt |
Whether the shutdown guard lifts maintenance mode when an update dies mid-flight. 'step_aware' (default) lifts for steps 1-4 and 8-10 but stays down for the half-applied steps 5-7; true always lifts; false never lifts. Env: CMS_UPDATES_LIFT_MAINTENANCE_ON_INTERRUPT. |
cms.updates.allow_insecure_transport |
Permit downloading a release archive over plaintext http, including via redirect. Default false. Env: CMS_UPDATES_ALLOW_INSECURE_TRANSPORT. |
cms.updates.backup_path |
Where snapshots are written. Relative paths resolve against storage_path(). Also bounds which archives update:rollback will restore without --allow-external. |
cms.updates.queue.connection |
Connection a queued update is dispatched to. Null uses queue.default. Env: CMS_UPDATES_QUEUE_CONNECTION. |
cms.updates.queue.queue |
Queue name to push onto. Null uses the connection's default queue. Env: CMS_UPDATES_QUEUE. |
cms.updates.queue.timeout |
Seconds the worker allows the job. Null derives it from download_timeout + composer_timeout + 900s. Env: CMS_UPDATES_QUEUE_TIMEOUT. |
cms.updates.queue.allow_sync |
Opt in to dispatching onto the sync driver, which runs the update inline and blocks the caller. Default false. Env: CMS_UPDATES_QUEUE_ALLOW_SYNC. |
Environment variables:
| Var | Purpose |
|---|---|
UPDATE_APPLICATION_SLUG |
Slug identifying the application to the update source and keying its update-check cache. Default application. |
UPDATE_CHECK_FREQUENCY |
Advisory cadence for update:check-scheduled; the host still has to schedule the command. Default daily. |
COMPOSER_BINARY |
Absolute path to composer, exposed to cms.updates.composer_binary via env(). |
CMS_PHP_BINARY |
Absolute path to a CLI PHP binary; overrides PHP_BINARY when the updater invokes composer. |
CMS_UPDATES_ALLOW_UNVERIFIED |
Boolean; opts into warn-and-continue when the source omits a SHA-256 checksum. |
CMS_UPDATES_LIFT_MAINTENANCE_ON_INTERRUPT |
'step_aware' (default), true, or false. Step-aware keeps the site down only for interruptions in the half-applied steps 5-7; false leaves it down after any interruption; true always lifts. |
CMS_UPDATES_VERIFY_LOCK_SYNC |
Boolean; set false to skip the composer.json/composer.lock sync pre-flight check. |
CMS_UPDATES_RECOVER_STALE_LOCK |
Boolean; set false to roll back rather than run a targeted composer update when a stale previous-release lock aborts the install. |
CMS_UPDATES_ALLOW_INSECURE_TRANSPORT |
Boolean; set true to allow plaintext-http downloads on a trusted air-gapped mirror. |
CMS_UPDATES_QUEUE_CONNECTION |
Queue connection a queued update is dispatched to. |
CMS_UPDATES_QUEUE |
Queue name a queued update is pushed onto. |
CMS_UPDATES_QUEUE_TIMEOUT |
Seconds the worker allows a queued update before killing it. |
CMS_UPDATES_QUEUE_ALLOW_SYNC |
Boolean; set true to permit dispatching onto the blocking sync driver. |
Command reference
| Command | Purpose |
|---|---|
php artisan update:check |
Report whether an update is available. |
php artisan update:perform |
Run the ten-step update. --target-version=x.y.z pins a release; --allow-downgrade permits a target that is not newer than the installed version; --queue dispatches it to a worker instead of running it here. |
php artisan update:rollback |
Restore a snapshot. Takes an optional path; defaults to the newest archive in backup_path. --allow-external permits a path outside that directory; --force skips the confirmation prompt. |
php artisan update:status |
Report the most recent run, including a queued run that no worker has claimed. Exits non-zero when it failed or was interrupted, in both output modes. --json emits the raw record; --clear discards it after reporting. |
php artisan cms:plugins:check-updates |
Re-check every plugin against its update source, bypassing cached answers, and cache the results. Schedulable; registered but not scheduled, like update:check-scheduled. Exits non-zero when any plugin's check failed — the others are still checked. |
php artisan cms:themes:check-updates |
The same for every installed theme. |

