FrankenPHP Runtime
Lerd can serve a PHP site through a per-site FrankenPHP container instead of the shared PHP-FPM container. FrankenPHP keeps PHP resident in memory, serves HTTP directly, and supports a worker mode that reuses a single PHP process across requests.
The FrankenPHP runtime is opt-in, framework-agnostic, and coexists with the default FPM runtime on the same machine. Laravel sites (via Octane) and Symfony sites (via the native worker flag) are both supported out of the box; any other PHP framework with a public/index.php gets the generic frankenphp php-server entrypoint.
Switching runtime
Two equivalent ways to turn FrankenPHP on for a site.
From .lerd.yaml (commits the choice to the repo, so everyone who links the project gets the same runtime):
runtime: frankenphp
runtime_worker: trueFrom the CLI:
cd ~/Code/my-app
lerd runtime frankenphp --workerFlip back to FPM with lerd runtime fpm. lerd runtime without an argument prints the current runtime. Both surfaces restart the container, regenerate the nginx vhost, and reload nginx automatically.
If a site loses its runtime: frankenphp line in .lerd.yaml (a git revert, a branch switch, a manual edit) and you re-link it, lerd reconciles the change for you: the leftover lerd-fp-<site>.container quadlet and its container are removed during the link, so a stale FrankenPHP container is never left running outside lerd's start/stop control.
What happens under the hood
For a FrankenPHP site, lerd:
- Pulls
dunglas/frankenphp:php<version>-alpinefor the site's PHP version (defaults 8.2, 8.3, 8.4; unsupported versions fall back to 8.4). - Writes a per-site quadlet
lerd-fp-<site>.containerthat mounts the project at its host path and runs the framework's entrypoint. - Generates an nginx vhost that reverse-proxies to
lerd-fp-<site>:8000. - Starts the container, reloads nginx.
The container joins the shared lerd Podman network, so services like lerd-mysql, lerd-redis, and lerd-meilisearch are reachable by hostname.
Pause semantics mirror FPM: lerd pause <site> (or the dashboard pause toggle, or site_control action=pause) stops lerd-fp-<site> alongside the paused-vhost swap, and lerd unpause <site> starts it again. The container is only running while the site is active, so a long-idle FrankenPHP site doesn't keep a process resident.
Framework adapters
Each framework can declare how to launch FrankenPHP via a frankenphp: block in its definition. Both built-in adapters ship with one.
Laravel has two modes:
- Non-worker (
runtime_worker: false, default): lerd runsfrankenphp php-server -r public/. Each request boots Laravel from scratch; code edits take effect on the next request, same as FPM. You still get FrankenPHP's HTTP/2, HTTP/3, and TLS, but not Octane's per-request speedup. - Worker (
runtime_worker: true): lerd runsphp artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000 --workers=auto. Octane keeps Laravel resident; requests skip the full bootstrap. Octane registers Symfony Console signal handlers which need thepcntlPHP extension; it is baked into lerd's derived FrankenPHP image (see the Extensions section) so the container boots straight into Octane.
Symfony uses FrankenPHP's native worker flag:
frankenphp php-server -l :8000 -r public/ [--worker=public/index.php --watch]In worker mode lerd also passes --watch, which reloads the resident worker on any .php, .env, .yaml, or .twig change, so file edits take effect without a manual restart. runtime/frankenphp-symfony (optional) plugs Symfony's Runtime into the same worker loop for slightly lower per-request overhead.
Any other framework with a public/index.php falls back to:
frankenphp php-server -l :8000 -r <public_dir>To override the defaults for a specific project, add a user framework overlay at ~/.config/lerd/frameworks/<name>.yaml with a frankenphp: block, or commit a full framework definition alongside the project.
Workers
Queue workers, schedulers, Reverb, Horizon, and any framework-defined worker continue to work unchanged: lerd spawns each as its own systemd service and podman execs into the FrankenPHP container for the site. Laravel queue:work and Symfony messenger:consume both run alongside the web worker without conflict.
Start a worker the same way you would on an FPM site:
cd ~/Code/my-app
lerd worker start queue # Laravel
lerd worker start messenger # SymfonyWorker mode on vs off
Both modes use the same FrankenPHP binary, so you always get HTTP/2, HTTP/3, and TLS for free. The difference is what happens inside the PHP process for each request.
Worker off (default): each incoming request runs public/index.php from scratch. The framework boots (container, DI, config cache, routes, middleware stack, etc.) on every hit, same as classic PHP-FPM. Memory resets between requests; file edits take effect on the next request.
Worker on: FrankenPHP keeps one resident PHP process alive and calls frankenphp_handle_request() in a loop. The framework boots once, then the warm worker handles every subsequent request by reusing the already-constructed DI container, cached routes, resolved config, etc. Requests are typically 10x to 50x faster because you skip the bootstrap each time.
Tradeoffs of worker mode:
- State leaks across requests. Anything you stored in a static property, a singleton service, or the global
$_SERVER/$_SESSIONarrays from request A is still there for request B. This is usually fine for well-written frameworks (Octane's "state resetters" and Symfony's Runtime handle the common cases), but custom code that assumes a fresh process per request can misbehave. - File edits are not picked up automatically. The worker holds PHP in memory, so editing a controller doesn't affect the next request until the worker reloads. Symfony worker mode passes
--watchso edits reload the worker within a second or two; Laravel worker mode reloads when you opt in withlerd octane:reload on(see Dev iteration and hot reload), otherwise it needslerd restart <site>orlerd runtime fpm. - Memory usage grows over time. Leaks that would be invisible in FPM (where each request gets a fresh process) become visible over thousands of requests.
Typical usage:
- Local dev, iterating on code: worker off, or Symfony worker on (auto reload). Laravel dev is usually happier with worker off or the shared FPM runtime.
- Benchmarking, perf testing, staging: worker on — this is the realistic production picture.
- CI / ephemeral environments: worker off — simpler, no state-leak surprises.
Dev iteration and hot reload
Non-worker mode (the default) serves each request with a fresh PHP request lifecycle for both Laravel and Symfony, so file edits take effect on the next request, just like FPM. That's the right default for local iteration.
Worker mode keeps PHP resident, so a source file change is not picked up on the next request unless the worker is told to reload:
Symfony worker mode passes
--watchtofrankenphp php-server, so edits under the project tree reload the worker within a second or two.Laravel worker mode is opt-in for auto-reload:
bashcd ~/Code/my-app lerd octane:reload on # serve via octane:start --watch lerd octane:reload off # back to standard worker mode lerd octane:reload # print the current stateWhen on, lerd serves the site with
octane:start --watchso edits restart the resident workers within a second or two. The toggle is also a refresh button next to the Octane segment in the Web UI site controls. Two prerequisites are handled for you:- Octane's file watcher runs under
nodeand resolveschokidarfrom the project. Reload-on stays off untilchokidaris installed; the CLI and the Web UI both offer a one-clicknpm install -D chokidar(Vite 8 no longer ships it transitively). Node is baked into lerd's derived FrankenPHP image, so the watcher works without an install step at boot. - On macOS (and WSL2
/mntprojects) the container can't observe host filesystem events, so lerd appends--pollautomatically.
If you'd rather not enable reload, the older workarounds still apply:
lerd restart <site>(~5s),php artisan octane:reloadinside the project (drops warm workers without restarting the container), orlerd runtime frankenphp --no-workerto hot-reload every request like FPM.- Octane's file watcher runs under
Extensions and debug tooling
lerd builds a derived image, localhost/lerd-frankenphp<version>:local, FROM the dunglas base with the same runtime extension set the FPM image ships (redis, gd, pdo_mysql/pgsql, intl, imagick, igbinary, mongodb, gmp, bcmath, soap, ldap, zip, ...), plus any extensions and packages you add globally. They are compiled for the ZTS runtime and baked once, so pcntl and nodejs are present from first boot rather than installed at container start. The image rebuilds automatically when lerd's definition changes or via lerd php:rebuild.
The per-request debug tooling works for requests Octane serves too: lerd bind-mounts the same dump bridge, lerd_devtools (the Debug window's query/job/view/mail/event/http capture), and Xdebug config into the FrankenPHP container. lerd dump on, the Debug window, and the Xdebug toggle all apply to a FrankenPHP site. dump()/dd() and captured queries from a live Octane request land in the dashboard exactly as they do under FPM. The one exception is lerd xdebug pause, which breaks into a running worker through xdebugctl, a tool only the shared FPM image ships, so it stays PHP-FPM only; the Xdebug on/off/mode toggle still works on FrankenPHP.
CLI tooling (lerd test, lerd pest, lerd php:bun, lerd pest:browser, php, composer) execs into the shared FPM container for the site's PHP version, so bun and Pest browser testing work for FrankenPHP sites with no extra setup.
php.ini is edited per site on FrankenPHP, not per PHP version. A FrankenPHP site runs its own container, so it has its own php.ini file edited from a php.ini tab in the site's config modal (the gear/Nginx button); the change applies to that site only and restarts just its container. This is different from FPM sites, which share one per-version php.ini edited under System → PHP. The System → PHP per-version editor does not affect FrankenPHP sites.
What is not supported on FrankenPHP
Everything the FPM runtime offers works on FrankenPHP except one thing:
- SPX profiler (
lerd profile). SPX profiles per request and does not hook Octane's resident-worker loop (its/_spxUI 404s under Octane, and lerd's profiler injection is fastcgi-only), so it stays FPM-only. The global profiler toggle still profiles your FPM sites; to profile a FrankenPHP site with SPX, switch it back to FPM (lerd runtime fpm, run from the project).
Everything else — the full runtime extension set, Xdebug, the dump()/dd() bridge, the Debug window (lerd_devtools), per-site php.ini, bun, and Pest browser testing — is supported.
Other notes
- PHP version picker (in the Web UI and
lerd isolate) rebuilds the derived image for the matchingdunglas/frankenphp:php<version>-alpinebase and restarts the site. On a FrankenPHP site the dashboard dropdown only lists the versions FrankenPHP publishes an image for, so it never offers one that would silently downgrade the site; a site pinned to a version like 8.5 runs that image rather than dropping back to 8.4. - macOS works the same way as Linux because FrankenPHP runs inside the Podman Machine VM; no extra wiring required.
Runtime badge
The Web UI site detail panel shows an orange FrankenPHP badge next to the framework and services, with a worker suffix when worker mode is on. The same badge appears in lerd tui beside the PHP version line.
Related pages
- Per-project custom container — for non-PHP apps or a fully custom image.
- Web UI and TUI — where the runtime badge appears.
- Environment setup —
.envwiring is identical under both runtimes.