Search by

ochorocho / frankenphp

ochorocho

FrankenPHP worker mode support for TYPO3

Package info

github.com/ochorocho/typo3-frankenphp

Type:typo3-cms-extension

pkg:composer/ochorocho/frankenphp

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

0.0.3 2026-05-25 19:17 UTC

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_install queries to canonical public/index.php (which TYPO3 ships and which handles Bootstrap::init($failsafe=true) internally), serves static assets (_assets/, fileadmin/, typo3temp/assets/) directly with the cache lifetimes of TYPO3's .htaccess and 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 class with 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, ServerRequestInterface or $GLOBALS['TYPO3_REQUEST'] into a shared service. Read them from the request that is passed to the method.
  • Keep GeneralUtility::setSingletonInstance() out of ext_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-Discarded must 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.js for 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_PORT to the Caddyfile global block.
  • METRICS_PORT= (default 2019) 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 canonical public/index.php ( which boots with $failsafe=true and runs InstallApplication). Works without backend login but requires the unlock file public/typo3conf/ENABLE_INSTALL_TOOL (create via touch 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, sqlite3 on $PATH.
  • frankenphp binary on $PATH — see https://frankenphp.dev/docs/.
  • ImageMagick is optional and auto-detected by setup-typo3.sh. Override with MAGICK_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, including intl. Nothing to install.
  • Deb-packaged PHP — the php-zts-* packages come from the same static-php repository that provides apt 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

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 -k to 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.