ochorocho / frankenphp
FrankenPHP worker mode support for TYPO3
Package info
github.com/ochorocho/typo3-frankenphp
Type:typo3-cms-extension
pkg:composer/ochorocho/frankenphp
Requires
- cweagans/composer-patches: ^1.7
- helhum/dotenv-connector: ^3.2
- typo3/cms-core: ^14.3 || 15.*@dev
Requires (Dev)
- composer/composer: ^2.7
- friendsofphp/php-cs-fixer: ^3.64
- phpstan/phpstan: ^1.12
- typo3/cms-adminpanel: ^14.3
- typo3/cms-backend: ^14.3
- typo3/cms-dashboard: ^14.3
- typo3/cms-form: ^14.3
- typo3/cms-workspaces: ^14.3
- typo3/coding-standards: ^0.8
Suggests
- typo3/cms-dashboard: Required for the live Prometheus metrics dashboard widget — without it the widget gracefully no-ops.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-14 18:56:22 UTC
README
Provides one CLI command that generates everything needed to run TYPO3 under FrankenPHP:
vendor/bin/typo3 frankenphp:init
# or without prompts:
vendor/bin/typo3 frankenphp:init --no-interaction
# overwrite existing files:
vendor/bin/typo3 frankenphp:init --no-interaction --force
# production defaults (ports 80/443, TYPO3_CONTEXT=Production, larger worker pool):
vendor/bin/typo3 frankenphp:init --profile prod
# expose Caddy + FrankenPHP Prometheus metrics on localhost:METRICS_PORT (default 2019):
vendor/bin/typo3 frankenphp:init --prometheus
--profile dev|prod drives the defaults for ports, TYPO3_CONTEXT, worker count, and max_requests. --prometheus
adds the metrics directive and an admin localhost:METRICS_PORT block to the Caddyfile and powers the live dashboard
widget described below.
Composer also runs the command automatically on package install / update / dump-autoload via the TYPO3
installer-scripts mechanism, so users who don't touch the CLI still get a working setup. Without --force, the command
preserves files that already exist (warns instead of overwriting), so a composer update won't clobber a hand-edited
Caddyfile, .env, php.ini, or public/worker.php.
The files:
- Worker entrypoint:
public/worker.php— long-running FrankenPHP worker for the full backend + frontend (HttpApplication). - Webserver config:
Caddyfile— routes?__typo3_installqueries to canonicalpublic/index.php(which TYPO3 ships and which handlesBootstrap::init($failsafe=true)internally), serves static assets (_assets/,fileadmin/,typo3temp/assets/) directly with the cache lifetimes of TYPO3's.htaccessand precompressed sidecars, and rewrites everything else straight to the worker without a filesystem lookup. - Environment config:
.env - PHP runtime config:
php.ini— profile-aware (e.g.display_errors,opcache.validate_timestamps).
The install-tool recovery URL needs Bootstrap::init with $failsafe=true so the container exposes
InstallApplication — mutually exclusive with the worker's always-on HttpApplication boot. Rather than shipping a
duplicate entry-point, the Caddyfile routes those requests to TYPO3's existing canonical public/index.php, which
already implements that branch. index.php is not registered as a FrankenPHP worker, so requests reach it as standard
per-request PHP execution.
For users — install into an existing TYPO3
composer require ochorocho/frankenphp
Then run FrankenPHP from the project root using the created config files (Caddyfile, .env). The flag is
--envfile: Caddy silently ignores an unknown -e, and without the env file only the Caddyfile's built-in defaults
apply (PHP still sees .env when helhum/dotenv-connector is installed, which hides the mistake):
frankenphp run -c Caddyfile --envfile .env
The generated profile follows TYPO3_CONTEXT: with TYPO3_CONTEXT=Production set during composer install, the
Composer auto-run writes the production profile (ports 80/443, errors logged not displayed, Caddy admin API off). Any
other context yields the development profile. Pass --profile to override, and note that --force backs up an
existing .env to .env.bak-<timestamp> before overwriting it.
Diagnostics
The TYPO3 backend's System Information dropdown (the info icon in the topbar) shows a Worker Mode row —
Enabled when the current request is being served by the long-running FrankenPHP worker, Disabled when served by
per-request PHP execution (e.g. the install-tool recovery URL via /index.php). The row icon is the FrankenPHP mascot (
the skeleton elephant from frankenphp.dev). Use it to quickly verify that requests you expect to hit the worker
actually do.
Which services survive a request?
vendor/bin/typo3 frankenphp:audit # grouped summary + full table vendor/bin/typo3 frankenphp:audit --summary # counts and the top demotion causes only vendor/bin/typo3 frankenphp:audit --format=markdown --filter=Extbase
In a TYPO3 project this is the project's vendor/bin/typo3. In this repository it also works from the root:
typo3/cms-composer-installers is allowed in the root composer.json, so composer install generates the package
artifact from the installed typo3/cms-* packages and the CLI boots without a settings.php (the extension's own
installer script skips frankenphp:init when the root package is not a typo3-cms-project). The root run classifies
Core plus the dev-dependency extensions; the Build/ sandbox (scripts/typo3 frankenphp:audit) additionally covers
whatever else is installed there. The classification is computed when the DI container is compiled, so flush
caches (vendor/bin/typo3 cache:flush) after changing KeepList, service tags or a FrankenPhpWorker.php.
Worker mode is discard-by-default: every DI service instance that is not provably stateless (or explicitly pinned)
is dropped at the start of each request and rebuilt lazily by the compiled container. The audit prints the
classification with reasons so you can see why a service is kept or discarded and tune it: extension authors use
the frankenphp.keep / frankenphp.discard service tags or a declarative Configuration/FrankenPhpWorker.php
(pin / keep / discard / discardPatterns, also for services of other packages), integrators get the final
say in config/system/frankenphp-worker.php. An explicit discard always wins over a keep, and the audit shows the
origin of every override as config:<extension key>. In Development context every response carries tuning headers:
X-FrankenPHP-Discarded (instances the previous request left behind; should stabilise after warm-up, growth means a
leak), X-FrankenPHP-Reset-Us (microseconds of reset inside this request), X-FrankenPHP-Post-Reset-Us (microseconds
of the structural reset that ran after the previous response) and X-FrankenPHP-Reset-Mode (post, or inline when
the worker fell back to resetting in front of the request). Details, including measured costs:
Documentation/WorkerMode.md.
What a request costs
Measured on the sandbox (SQLite, 10 cores): a cached frontend page takes 7 to 9 ms end to end with one
client, 4.3 ms of it inside PHP. The extension's own per-request work is 0.2 ms for the reset plus about
0.5 ms to rebuild discarded services. The machine serves 750 to 850 such pages per second before it is
CPU-bound, so in a closed-loop load test the average is concurrency divided by that throughput: about
10 ms at 8 concurrent clients, 25 ms at 20. Kept database connections, file-based caches in the sandbox and
one worker thread per core are what brought it there. Measured at a fixed rate instead of a closed loop
(Tests/load/scenarios/frontend-latency.js, 300 requests per second) a cached page averages 4.6 ms. The breakdown, both test matrices and the deployment
checklist (real database server, Redis or APCu, cores) are in
Documentation/Performance.md.
Adapting an extension for worker mode
Under PHP-FPM every request starts from a fresh process. In a worker, DI service instances live until something drops them, and this extension drops every instance it cannot prove stateless at the start of each request. Most extensions therefore work unchanged. What is left for an author is to check the verdict, write services so they are kept, and override the analysis where it is wrong.
1. Read the audit for your namespace
vendor/bin/typo3 cache:flush && vendor/bin/typo3 frankenphp:audit --filter='Vendor\MyExt'
| Reason | Meaning for you |
|---|---|
keep / readonly, keep / readonly-props |
Kept across requests. Nothing to do. |
discard / mutable |
Rebuilt per request. Correct by default; only worth changing for expensive boot-populated registries. |
discard / demoted-via:<service> |
Would be kept, but holds a discarded service, a non-shared service or RequestId. Fix the dependency, not the holder. |
discard / pattern |
*Controller, *ToolbarItem: always per request. |
discard / pin-conflict:<chain> |
A pin whose closure reaches a per-request service. The chain in the reason shows where. |
2. Write services so they are kept automatically
- Prefer
final readonly classwith constructor promotion. Every instance property readonly means "kept". - Do not store request data (the request, the backend user, the site, the language, results of the last call) in a shared service. One mutable property turns the service and everything that holds it into per-request objects.
- Never inject
RequestId,ServerRequestInterfaceor$GLOBALS['TYPO3_REQUEST']into a shared service. Read them from the request that is passed to the method. - Keep
GeneralUtility::setSingletonInstance()out ofext_localconf.php: that registry is purged per request. - Static properties are never reset. The audit lists them as
static: …; avoid them for anything request-related. - A registry that collects service instances from a tagged iterator (toolbar items, widgets, link types, MFA providers) must stay per request. Kept, it carries the state of whichever request built it first.
3. Override the analysis where it is wrong
Three hooks, all inert when this extension is not installed. All are read when the DI container is compiled, so
run cache:flush after editing.
| You want to | Use |
|---|---|
| Keep or discard one of your own services | frankenphp.keep (mode: soft or pinned) / frankenphp.discard tag in Services.yaml, or #[AutoconfigureTag] on the class |
| Address services of other packages, use patterns, or ship one file per package | Configuration/FrankenPhpWorker.php (below) |
| Give the project the final say over every extension | config/system/frankenphp-worker.php, same format |
| Clear the state of a pinned service each request | Listener on WorkerRequestStartingEvent |
<?php // EXT:my_ext/Configuration/FrankenPhpWorker.php — every key optional, pure data return [ 'pin' => [\Vendor\MyExt\Registry\FormatRegistry::class], // kept with its dependency closure 'keep' => [\Vendor\MyExt\Service\PriceCalculator::class], // kept while its closure stays clean 'discard' => [\Vendor\MyExt\Service\RequestScopedCollector::class, 'my_ext.legacy.service.id'], 'discardPatterns' => ['/^Vendor\\\\MyExt\\\\Controller\\\\/'], // matched against id and class ];
Precedence is one rule: an explicit discard from any source (curated list, tag, pattern, file) wins over a keep or
pin from any other source. The only exception is the curated boot-populated infrastructure (TcaSchemaFactory,
IconRegistry, cache.runtime, …) and its dependencies: a file cannot discard those, the audit shows the ignored
request as pinned:ignored-discard:…. keep is soft and can still be demoted by its dependencies; a pin whose
closure reaches a per-request service is reported as a pin conflict and not kept. Keeping a Core service you do not
own can leak one user's state to the next: verify with two backend users, discard is always the safe direction.
The audit shows the origin of every override as config:<extension key>, pinned:config:<extension key> or
pattern:config:<extension key>, and warns about entries that matched no service. A class name also reaches a
service registered under a custom id. Unknown keys, non-string entries or invalid patterns fail the container build
with the file name in the message.
4. Verify
- Rerun the audit: your overrides show up with their origin.
- In Development context,
X-FrankenPHP-Discardedmust stabilise after a few requests; growth means state leaks. - Exercise the extension with two backend users of different permissions on the same worker pool. State that
survives a request shows up as duplicated UI elements, wrong menus or wrong permissions.
Tests/e2e/in this repository is a template for such specs,Tests/load/scenarios/backend-multiuser.jsfor a k6 soak.
Prometheus metrics dashboard widget
Run vendor/bin/typo3 frankenphp:init --prometheus (add --force to overwrite an existing Caddyfile / .env). This
adds:
metrics+admin localhost:METRICS_PORTto the Caddyfile global block.METRICS_PORT=(default2019) to.env.
A dashboard widget titled Prometheus Metrics then appears in the FrankenPHP widget group. It charts the metric you
pick — FrankenPHP worker-pool gauges, Caddy HTTP counters/histograms, or Go runtime stats — by polling the backend AJAX
route ajax_frankenphp_metrics (Configuration/Backend/AjaxRoutes.php), which proxies
http://127.0.0.1:METRICS_PORT/metrics. The proxy exists because Caddy's admin endpoint rejects any browser request
that ships an Origin header; only server-side scrapers (this proxy, Prometheus, curl) can reach it directly.
The curated metric list lives in PrometheusMetricsWidget::METRIC_CHOICES. Enumerate what your build actually exposes
with:
curl http://localhost:2019/metrics | grep "^# TYPE"
Install Tool access
Two URLs reach the TYPO3 Install Tool, each routed differently:
https://your-host/typo3/install— preferred for normal maintenance. Goes through the worker via the standard backend route. Requires a logged-in admin backend session.https://your-host/?__typo3_install— recovery URL. Caddy routes this to TYPO3's canonicalpublic/index.php( which boots with$failsafe=trueand runsInstallApplication). Works without backend login but requires the unlock filepublic/typo3conf/ENABLE_INSTALL_TOOL(create viatouch public/typo3conf/ENABLE_INSTALL_TOOL; auto-removed after one hour). Also accepts the standard controller-routing query parameters, e.g.?__typo3_install&install[controller]=maintenance.
If you ever change the Caddyfile manually and forget to keep the @typo3_install matcher, the recovery URL will 404 —
vendor/bin/typo3 frankenphp:init --force --no-interaction regenerates a working config.
Action URLs are AJAX-only
URLs that carry both install[controller]=… and install[action]=… (anything other than install[controller]=layout)
are designed for the install tool's own JS to call via XMLHttpRequest. They return a JSON envelope
{success: true, html: '…', buttons: [...]} for the JS to inject into a modal. Pasting such a URL into a browser
address bar shows the raw JSON, not a usable page.
To avoid that confusion, the Caddyfile's @install_browser_ajax matcher detects browser top-level navigation (
Sec-Fetch-Mode: navigate + Sec-Fetch-Dest: document, without X-Requested-With: XMLHttpRequest) to
?__typo3_install&install[action]=… URLs and redirects (302) to the install tool dashboard at /?__typo3_install. From
there, click into the relevant tile (Maintenance, Settings, Upgrade, Environment). The redirect is a Caddyfile-level
concern; no extra PHP entry-point is involved.
For long-running maintenance like the reference index, the CLI alternative is usually preferable — the install tool's own UI literally points at this:
vendor/bin/typo3 referenceindex:update -c # check only vendor/bin/typo3 referenceindex:update # rebuild
Repository layout
This repository is the extension package, not a TYPO3 installation. A throwaway TYPO3 sandbox is materialized in
Build/ (gitignored) so the extension can be exercised end-to-end.
| Folder | Purpose |
|---|---|
Classes/ |
Extension PHP source — Command/ (frankenphp:init), Controller/Backend/ (metrics AJAX proxy), Service/ (PrometheusTextParser), Widget/ (PrometheusMetricsWidget), Worker/ (WorkerStateResetter + KeepList — per-request reset of the worker process), DependencyInjection/ (WorkerKeepListPass — compile-time keep/discard classification), EventListener/, Middleware/, Event/, Composer/ (TYPO3 installer-scripts hook). |
Configuration/ |
TYPO3 service wiring (Services.yaml, Services.php), Backend/AjaxRoutes.php, Backend/DashboardWidgetGroups.php, Backend/DashboardPresets.php, JavaScriptModules.php, Icons.php, RequestMiddlewares.php. |
Resources/Private/ |
Fluid templates (Templates/Widget/), Language/ XLF files, and Php/worker.php — the template InitCommand copies into the user's public/. |
Resources/Public/ |
Frontend assets — JavaScript/widget/ (Chart.js-backed Lit web component for the metrics widget), Css/widget/, Icons/. |
Tests/ |
Unit/ PHPUnit tests for the compiler pass, e2e/ Playwright suite (correctness) and load/ k6 scenarios (performance + worker stability). Each has its own README. |
Documentation/ |
WorkerMode.md — the service lifecycle model and the audited list of Core services for worker mode. |
scripts/ |
Developer bootstrap — setup-typo3.sh materializes the Build/ sandbox; typo3 runs the sandbox CLI from the repo root. |
Build/ |
Gitignored. Throwaway TYPO3 install for development. Build/composer.json requires this extension via a Composer path repository pointing at ../, so edits to root Classes/ / Resources/ / Configuration/ affect the sandbox immediately. |
Contributing
Prerequisites
- PHP 8.3+, Composer,
sqlite3on$PATH. frankenphpbinary on$PATH— see https://frankenphp.dev/docs/.- ImageMagick is optional and auto-detected by
setup-typo3.sh. Override withMAGICK_BIN=/abs/path/to/magick.
PHP extensions (Ubuntu / Debian)
TYPO3 needs intl: without it the Filelist/Media module fails with
Class "Collator" not found (FileList::sortResources() builds a \Collator for every folder listing).
FrankenPHP embeds thread-safe (ZTS) PHP, so Ubuntu's own php8.x-* packages don't apply — they're
non-thread-safe and their .so files won't load. Two ways to get the extensions:
- Prebuilt/static binary (
curl https://frankenphp.dev/install.sh | sh) — bundles PHP and most popular extensions, includingintl. Nothing to install. - Deb-packaged PHP — the
php-zts-*packages come from the same static-php repository that providesapt install frankenphp(see https://frankenphp.dev/docs/):
VERSION=85 # 82-85 available sudo curl https://pkg.henderkes.com/api/packages/${VERSION}/debian/repository.key -o /etc/apt/keyrings/static-php${VERSION}.asc echo "deb [signed-by=/etc/apt/keyrings/static-php${VERSION}.asc] https://pkg.henderkes.com/api/packages/${VERSION}/debian php-zts main" | sudo tee -a /etc/apt/sources.list.d/static-php${VERSION}.list sudo apt update sudo apt install frankenphp # Required for TYPO3 (sqlite sandbox): sudo apt install php-zts-intl php-zts-gd php-zts-zip php-zts-sqlite3 php-zts-pdo-sqlite # Optional, depending on how you run the sandbox: sudo apt install php-zts-mysqli php-zts-pdo-mysql # TYPO3_DB_DRIVER=mysqli sudo apt install php-zts-apcu # TYPO3_CACHE_BACKEND=apcu sudo apt install php-zts-imagick # instead of the `magick` CLI
mbstring, dom, xml, simplexml, libxml, sodium, exif, fileinfo, opcache, openssl, ctype,
filter, iconv, session, tokenizer, zlib, curl and phar are already in php-zts-cli (it Provides:
them), and json / pcre / date are PHP core — none of them has a separate package. intl is the notable
exception, which is why it's the one that goes missing. For anything unpackaged use
PIE, not pecl: sudo apt install pie-zts && sudo pie-zts install <vendor/ext>.
Verify what the binary actually has:
frankenphp php-cli -r 'var_dump(class_exists("Collator"));' # expect bool(true) frankenphp php-cli -r 'print_r(get_loaded_extensions());' # full list
frankenphp php-cli -m does not work — it treats -m as a filename. Use -r as above.
The container equivalent is docker/Dockerfile (install-php-extensions exif gd imagick intl mysqli opcache zip).
When adding APCu by hand, write the absolute path to apcu.so in php.ini: FrankenPHP resolves a bare
extension=apcu against its compiled-in extension_dir, not the one the global php.ini declares
(see scripts/setup-typo3.sh and Documentation/Performance.md).
Bootstrap the dev sandbox
git clone git@github.com:ochorocho/typo3-frankenphp.git cd typo3-frankenphp scripts/setup-typo3.sh # TYPO3 ^14.3 (default) TYPO3_VERSION='^13.0' scripts/setup-typo3.sh # or any Composer constraint TYPO3_DB_DRIVER=mysqli scripts/setup-typo3.sh # local MariaDB/MySQL: database typo3_frankenphp, user typo3/typo3 TYPO3_CACHE_BACKEND=apcu scripts/setup-typo3.sh # TYPO3 hot caches in APCu (needs the apcu extension in FrankenPHP) BUILD_DIR=Build-mariadb TYPO3_DB_DRIVER=mysqli scripts/setup-typo3.sh # a second sandbox next to Build/ TYPO3_VERSION='15.*@dev' scripts/setup-typo3.sh
The script is idempotent. On a re-run with the same TYPO3_VERSION it skips work that's already done; with a
different version it resets Build/vendor/, composer.lock, config/system/, and var/cache before re-installing —
so switching TYPO3 majors is one command. It writes a Build/composer.json that requires the extension as a symlinked
path repository ("url": "../"), so editing root files affects the sandbox immediately with no extra step.
The sandbox and the Docker setup use fixed, well-known credentials (backend admin, e2e editor, MariaDB root) and run
with display_errors on. Keep them on localhost; never expose the sandbox ports to a network.
Admin login (created by typo3 setup): admin / Password.1. Override via:
TYPO3_SETUP_ADMIN_USERNAME=foo TYPO3_SETUP_ADMIN_PASSWORD='S3cret!' scripts/setup-typo3.sh
Run the dev server
cd Build && frankenphp run -c Caddyfile --envfile .env
- Frontend: http://localhost:8888 / https://localhost:8885
- Backend: https://localhost:8885/typo3
To regenerate Caddyfile / .env / php.ini / public/worker.php after switching profiles or toggling
--prometheus:
cd Build && vendor/bin/typo3 frankenphp:init --no-interaction --force
Run with Docker (no native FrankenPHP / Composer / PHP needed)
If you'd rather not install PHP, Composer, sqlite3, and the frankenphp binary on your host, a Docker Compose
setup is provided that runs everything in containers, backed by MariaDB instead of SQLite.
The first boot is slow — it fetches the images, provisions the FrankenPHP image, downloads all of TYPO3, and
runs typo3 setup.
# Run interactively docker compose up --build # Run detached and wait for FrankenPHP to become healthy docker compose up -d --build --wait
Subsequent docker compose up runs skip every already-completed step and start immediately.
Once the frankenphp-app became healthy, open the app in your browser:
- Backend: https://localhost:8885/typo3 (Login with
admin/Password.1)Your browser will warn about the self-signed certificate. Click through ("Advanced → Proceed") or use
curl -kto bypass it. - Frontend: http://localhost:8888/
The frontend won't have anything meaningful (e.g. site configuration) in this sandbox, yet.
Amend the configuration to your needs:
| Concern | Where to change it |
|---|---|
| Ports | HTTP_PORT / HTTPS_PORT in docker-compose.yml (or a Compose-level .env). Defaults: 8888 / 8885. |
| TYPO3 version | TYPO3_VERSION in docker-compose.yml (any Composer constraint, e.g. 15.*@dev). |
| Worker pool | FRANKENPHP_WORKER_COUNT / MAX_REQUESTS in docker-compose.yml. |
| DB / admin creds | TYPO3_DB_* / TYPO3_SETUP_* in docker-compose.yml. |
| Added PHP extensions | docker/Dockerfile. |
To rebuild from scratch (e.g. after changing TYPO3_VERSION), wipe the named volumes first:
docker compose down -v && docker compose up --build
Static analysis & code style
Dev dependencies are in the root composer.json; run the tools from the repository root:
vendor/bin/phpstan analyse --memory-limit=1G vendor/bin/php-cs-fixer fix --config=php-cs-fixer.php vendor/bin/phpunit
Tests
| Suite | Location | What it covers |
|---|---|---|
| Unit (PHPUnit) | Tests/Unit/ |
WorkerKeepListPass classification and dependency-closure rules against a small fixture container. |
| End-to-end (Playwright) | Tests/e2e/ |
Backend correctness against the running sandbox, including cross-user isolation on a shared worker. See Tests/README.md. |
| Load / soak (k6) | Tests/load/ |
Throughput, tail latency, and (most importantly) the regression net for Classes/Worker/WorkerStateResetter.php. See Tests/load/README.md. |
Submitting changes
Standard GitHub PR workflow against main. Please make sure phpstan and php-cs-fixer are clean and include a
Playwright or k6 test when the change is behavior-visible.