The removed runtime/frankenphp-symfony package force-restarted the
worker after FRANKENPHP_LOOP_MAX requests (default 500) - memory-leak
paranoia the built-in 8.1 runner intentionally doesn't reimplement.
Restore the behavior natively so the upgrade is a no-op operationally:
- Caddyfile: global frankenphp block with max_requests {$MAX_REQUESTS}
- per-thread graceful restarts, other threads keep serving
- Dockerfile: ARG/ENV MAX_REQUESTS=500 bakes the same default the old
package had, overridable at docker build or runtime (-e, 0 disables)
- Stock FRANKENPHP_CONFIG env var remains the full-config escape hatch
- Documented in readme env table, docs/example.env, CHANGELOG, and the
upgrade plan (phase-4 staging note now checks thread recycling)
11 KiB
Upgrade Plan: Symfony 7.4 → 8.1
Status: ✅ Implemented on branch feat/symfony-8.1-upgrade-plan
(Phases 0–3 & state audit complete; Phases 4–5 = staging + release)
Target: Symfony 8.1.* (all symfony components)
Was: Symfony 7.4.* → resolved 8.1.2–8.1.6
Prepared: 2026-09-07
Implementation results
| Phase | Result |
|---|---|
| 0 Deprecation sweep | ✅ Clean — suite runs with failOnDeprecation=true, zero hits on 7.4; the 8.x jump needed no app code changes. |
| 1 Composer bump | ✅ runtime/frankenphp-symfony removed, extra.runtime deleted, all symfony/* at 8.1.* (framework-bundle 8.1.6, twig-bundle 8.1.2); ride-alongs PHPUnit 13.3.2, Twig 3.28, otphp 11.5. Boots on v8.1.6. |
| 2 Config refresh | ✅ config/reference.php is gitignored, auto-regenerated by Flex. Prod cache:clear+cache:warmup, lint:container/lint:yaml/lint:twig all pass. |
| 3 Tests | ✅ 295 tests / 612 assertions green on 8.1; php-cs-fixer 0 fixable files. |
| State audit | ✅ All src/ services are final readonly with ctor-injected deps — no mutable state, kernel reuse under FrankenPhpWorkerRunner is safe. |
| Loop-max parity | ✅ Caddyfile sets max_requests {$MAX_REQUESTS}; default 500 baked into the image via Docker build arg (matches old package default), runtime-overridable. See §2 note. |
Phases 4–5 (staging smoke + release) are pending — everything else in this document describes what was planned and is now done.
1. Why we can leapfrog 8.0
Symfony 7.4 and 8.0 were released simultaneously (Nov 2025) and are
feature-identical — 8.0 is simply 7.4 with the deprecated code removed.
Because preauth is already on 7.4, we are on the last LTS bridge
release. The only gating question for 8.x is whether we still trigger
any deprecations. If composer test runs clean under 7.4 with
SYMFONY_DEPRECATIONS_HELPER strict, upgrading straight to 8.1 is safe
and avoids a double-bump of composer.json / composer.lock.
Symfony 8.1 (May 2026 cycle) also brings a runtime improvement we directly benefit from (see §3).
Prerequisites:
- ✅ PHP: Symfony 8.x requires PHP >= 8.4; composer.json already
requires
>= 8.4, Docker and CI run 8.5. No PHP work needed. - ⚠️ Deprecations: must be inventoried and fixed before the version bump (see Phase 0).
2. The runtime/frankenphp-symfony removal
We currently use the community runtime package for FrankenPHP worker
mode, wired in two places in composer.json:
"require": {
"runtime/frankenphp-symfony": "^1.0.0",
},
"extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
},
}
As of Symfony 7.4+, symfony/runtime ships its own
Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner, and in 8.1
the runtime handles FrankenPHP worker mode natively (including new
8.1 support for returning a Response from worker mode). The
community package is redundant.
Actions:
composer remove runtime/frankenphp-symfony(as part of the 8.1 bump in §4 — do it in the samecomposer updateto keep one lockfile diff).- Delete the entire
extra.runtimeblock fromcomposer.jsonso the defaultSymfony\Component\Runtime\GenericRuntimeis used; the built-inFrankenPhpWorkerRunneris auto-selected whenfrankenphp_handle_request()exists (i.e. inside FrankenPHP worker mode). Falling back to plainAPP_RUNTIME=Symfony\...\Runtimeenv override is possible but should not be needed. - Verify
symfony.lock— Flex should drop theruntime/frankenphp-symfonyentry automatically on removal. public/index.phpneeds no change — it already just returns the Kernel closure viaautoload_runtime.php.
Note on loop_max: the old package exposed
FRANKENPHP_LOOP_MAX (default 500). The built-in runner does not
read that env var. We never set it, so behavior is unchanged — but
check staging memory usage under worker mode and, if ever needed,
control restarts via FrankenPHP's own worker ... num N / max-requests
options in the Caddyfile instead.
3. composer.json changes
require
| Package | From | To |
|---|---|---|
symfony/cache |
7.4.* |
8.1.* |
symfony/console |
7.4.* |
8.1.* |
symfony/framework-bundle |
7.4.* |
8.1.* |
symfony/mime |
7.4.* |
8.1.* |
symfony/rate-limiter |
7.4.* |
8.1.* |
symfony/runtime |
7.4.* |
8.1.* |
symfony/twig-bundle |
7.4.* |
8.1.* |
symfony/uid |
7.4.* |
8.1.* |
symfony/yaml |
7.4.* |
8.1.* |
runtime/frankenphp-symfony |
^1.0.0 |
removed |
symfony/flex (^2.11), bacon/bacon-qr-code (^3) and
spomky-labs/otphp (^11) are compatible with 8.x — no change expected,
but let composer confirm during the update.
require-dev
| Package | From | To |
|---|---|---|
symfony/browser-kit |
7.4.* |
8.1.* |
symfony/css-selector |
7.4.* |
8.1.* |
phpunit/phpunit ^13.2 and friendsofphp/php-cs-fixer already support
PHP 8.5 / Symfony 8.
extra
"extra": {
- "runtime": {
- "class": "Runtime\\FrankenPhpSymfony\\Runtime"
- },
"symfony": {
"allow-contrib": false,
- "require": "7.4.*"
+ "require": "8.1.*"
}
}
One-shot command
composer update \
"symfony/*" \
--with-all-dependencies
# plus explicit remove of runtime/frankenphp-symfony beforehand
(Or edit composer.json, then composer update wholesale — the repo has
few non-Symfony deps, so a full update is low-risk.)
4. Config / recipes to re-sync
After the bump, run composer recipes:update (or
symfony console recipes:update) and review diffs for:
symfony/framework-bundle— checkconfig/packages/framework.yamlfor new/changed defaults (session, cache, http_method_override, etc.). Ourconfig/reference.phpdump is generated from 7.4 config; it must be regenerated after upgrade (bin/console config:dump-referenceequivalents) or it will document stale defaults.symfony/twig-bundle,symfony/rate-limiter— verifyconfig/packages/*.yamlagainst new reference defaults.symfony/runtime— new recipe may updatepublic/index.php; accept only if it's a no-op for our shape.
Also review bundles.php (only Framework + Twig today — no removals
expected in 8.x) and config/preload.php.
5. Code-level risk review
Preauth deliberately avoids the Security component (custom listeners +
ConfigBag), which removes the biggest 8.0 BC-break surface
(security.yaml reshaping, authenticator changes). Remaining surface:
- Listeners (
src/Listener/*): built on HttpKernel events — stable API, butKernelEventssignatures gained native types in 8.0; our listeners already declare types, verify covariance after upgrade. Kernel.php: confirm no overridden methods whose signatures changed in 8.0 (MicroKernelTrait is stable; likely no-op).symfony/console(GenerateBackupCodesCommand): 8.0 removed commandsetName()/aliases-in-constructor legacy paths — we use#[AsCommand], fine.Command::execute()must returnint— verify.spomky-labs/otphpandbacon/bacon-qr-code: third-party; confirm versions resolved are marked Symfony-8 compatible.- PHPUnit 13: no changes needed, but watch for deprecations printed
after the Symfony bump (new
trigger_deprecationcalls in 8.1).
Canonical checklist: read symfony/symfony UPGRADE-8.0.md and
UPGRADE-8.1.md sections for the components we require
(cache, console, framework-bundle, mime, rate-limiter, runtime,
twig-bundle, uid, yaml) and tick each item against this codebase.
6. Docker / CI
Dockerfile: no base-image change needed (dunglas/frankenphp:php8.5-trixie+php:8.5-trixiebuilder). Rebuild after composer.lock update; remove nothing — FrankenPHP itself stays.Caddyfile: unchanged (worker mode config is FrankenPHP-side, not runtime-package-side)..gitea/workflows/tests.yaml: PHP 8.5 already — unchanged.composer dump-env prod --emptystep stays.
7. Rollout plan
| Phase | Step | Exit criteria |
|---|---|---|
| 0 | Deprecation sweep on 7.4: run SYMFONY_DEPRECATIONS_HELPER=max[total]=0 composer test (or phpunit directly) + run the app in dev with the profiler/log; fix every direct deprecation. |
Zero deprecations from App\ code; only acceptable vendor ones documented. |
| 1 | composer bump: branch feat/symfony-8.1; edit composer.json per §3–§4; composer remove runtime/frankenphp-symfony; composer update; re-sync recipes. |
Installs clean on PHP 8.5; bin/console about shows 8.1.x. |
| 2 | Config refresh: regenerate config/reference.php; review framework/twig/rate-limiter defaults; commit config changes. |
cache:clear + warmup pass in dev & prod envs. |
| 3 | Tests: full phpunit suite + php-cs-fixer; fix failures (expected: minor — event/type related). | Suite green in CI. |
| 4 | Staging smoke: build image, run under FrankenPHP worker mode; verify TOTP login flow, backup codes, rate limiting (burst + teapot mode), public paths, central-auth subdomain flow; watch memory across >500 requests to confirm threads recycle via the Caddyfile max_requests setting (see §2 note). |
No state leaks across worker requests; worker threads recycle at the configured request count; healthcheck passes. |
| 5 | Docs + release: update readme/DESIGN_CONSIDERATIONS ("symfony 8.1, built-in FrankenPHP runtime"); tag a minor release per CHANGELOG conventions. | Release published; image rebuilt & pushed. |
Rollback: the upgrade is a single composer.lock + config diff.
Rollback = git revert the bump commit + redeploy previous image tag.
No data/schema migrations are involved (no database).
8. Open questions — resolved during implementation
Confirm none of our listeners/services relied on implicit behavior ofResolved: audited every class inRuntime\FrankenPhpSymfony\Runner.src/— all arefinal readonlywith constructor-injected dependencies and no mutable state. NoResetInterfaceneeded; kernel reuse across worker requests is safe.Decide whether to pinResolved: kept minor-pinnedsymfony/*as8.1.*or^8.1.8.1.*, matching repo convention.RegenerateResolved: it's gitignored and auto-regenerated by Flex onconfig/reference.php— scripted or manual dump?composer update; already refreshed for 8.1 during the bump.