Description
When the Extension Manager performs a global update (POST /api/extension-manager/global-update), the post-update "re-check for updates" runs synchronously, in the same PHP request that just rewrote vendor/. If that composer update bumped a dependency, the request is now executing against a half-swapped vendor/ (new PHP files on disk, but the autoloader/opcache for the live process still reflect the old set), and constructing a class from the freshly-updated dependency fatals — taking down the request before the update flow can finish.
A user reported being unable to complete the upgrade to v2.0.0-rc.4, hitting:
Error: Class "GuzzleHttp\Handler\CurlVersion" not found
in vendor/guzzlehttp/guzzle/src/Utils.php:108
full stack trace (trimmed)
#0 …/guzzle/src/HandlerStack.php(49): GuzzleHttp\Utils::chooseHandler()
#1 …/guzzle/src/Client.php(69): GuzzleHttp\HandlerStack::create()
#2 …/illuminate/container/Container.php(1171): GuzzleHttp\Client->__construct(Array)
…
#13 …/extension-manager/src/Listener/ReCheckForUpdates.php(49): Illuminate\Bus\Dispatcher->dispatch(CheckForUpdates)
#14 …/illuminate/events/Dispatcher.php(519): ReCheckForUpdates->handle(FlarumUpdated)
#17 …/extension-manager/src/Command/GlobalUpdateHandler.php(55): Dispatcher->dispatch(FlarumUpdated)
#24 …/extension-manager/src/Api/Controller/GlobalUpdateController.php(35): Job\Dispatcher->dispatch(GlobalUpdate)
Root cause
GuzzleHttp\Handler\CurlVersion was introduced in Guzzle 7.12.0 (released 2026-06-16; 7.12.1 followed on 2026-06-18) — both stable releases. It does not exist in 7.11.x. Core requires guzzlehttp/guzzle: ^7.7, so a global update legitimately bumps an older install straight onto the new stable 7.12. 7.12's Utils::chooseHandler() references the new CurlVersion class — which the running process can't autoload, because it started under 7.11.x.
This is not a minimum-stability / dev-version problem — the user pulled a normal stable release. Guzzle 7.12.0 was published on 2026-06-16, ~1.5 days before rc.4 (2026-06-18), so the 7.12 bump was available to any composer update independently of rc.4; rc.4 is not what introduced the exposure.
The chain, all within the one request:
GlobalUpdateHandler::handle() runs composer update --with-all-dependencies, rewriting vendor/ in place — bumping Guzzle 7.11.x → 7.12.x.
- It then synchronously dispatches
FlarumUpdated (GlobalUpdateHandler.php:56).
ReCheckForUpdates::handle() synchronously dispatches CheckForUpdates (ReCheckForUpdates.php:49).
- The container builds
CheckForUpdatesHandler, which has a constructor-injected GuzzleHttp\Client $http — constructing it calls Utils::chooseHandler(), which needs GuzzleHttp\Handler\CurlVersion. The process is still running with 7.11.x's autoloader/class map → Class not found fatal.
Composer having returned exit 0 to step 1 does not mean the install is in a usable state: the request dies partway through the flow, the same process can no longer load the new classes, and the admin is left with a 500 and the upgrade not completed.
This won't reproduce on every upgrade — it needs the update to actually move a dependency whose new code the already-booted process can't load (as Guzzle 7.12 does here). But when it hits, the user cannot complete the update from the panel (see below).
Impact
- The upgrade fails: a fatal/500 is surfaced to the admin and the update flow does not complete.
- The Extension Manager exposes no repair/reinstall action — only update/require/remove — so re-running "Update" just re-enters the same
GlobalUpdateHandler chain and can fail the same way. A user without CLI or host-panel composer access has no in-panel way to recover.
Fix
Don't perform the post-update re-check (the FlarumUpdated → ReCheckForUpdates → Guzzle Client work) in the same request that rewrote vendor/:
- Defer it to a queued job / fresh process (the extension already has queue machinery in
Job/Dispatcher with sync/async support), or
- Return after
composer update and let a subsequent, fresh request perform the re-check.
More broadly, anything that constructs or touches freshly-updated vendor/ classes within the same request that ran composer update is unsafe and should run in a new process. Worth also considering a recovery path for a half-applied update that doesn't require CLI access.
Environment
flarum/extension-manager 2.x
- Reported while upgrading to
flarum/core v2.0.0-rc.4, but the trigger is guzzlehttp/guzzle bumping 7.11.x → 7.12.x (7.12.0 released 2026-06-16, 7.12.1 on 2026-06-18). Any global update that moves Guzzle onto 7.12 can hit this; it is independent of the Flarum release being installed.
Description
When the Extension Manager performs a global update (
POST /api/extension-manager/global-update), the post-update "re-check for updates" runs synchronously, in the same PHP request that just rewrotevendor/. If thatcomposer updatebumped a dependency, the request is now executing against a half-swappedvendor/(new PHP files on disk, but the autoloader/opcache for the live process still reflect the old set), and constructing a class from the freshly-updated dependency fatals — taking down the request before the update flow can finish.A user reported being unable to complete the upgrade to
v2.0.0-rc.4, hitting:full stack trace (trimmed)
Root cause
GuzzleHttp\Handler\CurlVersionwas introduced in Guzzle 7.12.0 (released 2026-06-16; 7.12.1 followed on 2026-06-18) — both stable releases. It does not exist in 7.11.x. Core requiresguzzlehttp/guzzle: ^7.7, so a global update legitimately bumps an older install straight onto the new stable 7.12. 7.12'sUtils::chooseHandler()references the newCurlVersionclass — which the running process can't autoload, because it started under 7.11.x.This is not a minimum-stability / dev-version problem — the user pulled a normal stable release. Guzzle 7.12.0 was published on 2026-06-16, ~1.5 days before rc.4 (2026-06-18), so the 7.12 bump was available to any
composer updateindependently of rc.4; rc.4 is not what introduced the exposure.The chain, all within the one request:
GlobalUpdateHandler::handle()runscomposer update --with-all-dependencies, rewritingvendor/in place — bumping Guzzle 7.11.x → 7.12.x.FlarumUpdated(GlobalUpdateHandler.php:56).ReCheckForUpdates::handle()synchronously dispatchesCheckForUpdates(ReCheckForUpdates.php:49).CheckForUpdatesHandler, which has a constructor-injectedGuzzleHttp\Client $http— constructing it callsUtils::chooseHandler(), which needsGuzzleHttp\Handler\CurlVersion. The process is still running with 7.11.x's autoloader/class map →Class not foundfatal.Composer having returned exit 0 to step 1 does not mean the install is in a usable state: the request dies partway through the flow, the same process can no longer load the new classes, and the admin is left with a 500 and the upgrade not completed.
This won't reproduce on every upgrade — it needs the update to actually move a dependency whose new code the already-booted process can't load (as Guzzle 7.12 does here). But when it hits, the user cannot complete the update from the panel (see below).
Impact
GlobalUpdateHandlerchain and can fail the same way. A user without CLI or host-panel composer access has no in-panel way to recover.Fix
Don't perform the post-update re-check (the
FlarumUpdated→ReCheckForUpdates→ GuzzleClientwork) in the same request that rewrotevendor/:Job/Dispatcherwith sync/async support), orcomposer updateand let a subsequent, fresh request perform the re-check.More broadly, anything that constructs or touches freshly-updated
vendor/classes within the same request that rancomposer updateis unsafe and should run in a new process. Worth also considering a recovery path for a half-applied update that doesn't require CLI access.Environment
flarum/extension-manager2.xflarum/corev2.0.0-rc.4, but the trigger isguzzlehttp/guzzlebumping 7.11.x → 7.12.x (7.12.0 released 2026-06-16, 7.12.1 on 2026-06-18). Any global update that moves Guzzle onto 7.12 can hit this; it is independent of the Flarum release being installed.