PHP compatibility and project lifecycle
Per the supplied docs: Docker variants exist for PHP 8.2, 8.3, 8.4 and 8.5; binaries contain PHP 8.5; packages list versions 82-85. Current FrankenPHP version, license, pricing and support policy are unknown from the sources. This track targets PHP 8.4, with 8.5 planned.
Official sources checked 2026-10-22. Browser lessons on this site run PHP 8.5.
It was written only from the two official FrankenPHP documentation pages supplied for this guide.
What FrankenPHP is
The documentation describes FrankenPHP as a modern application server for PHP built on top of the Caddy web server. It is not a new PHP language implementation and not a historical alternative interpreter. It runs your PHP code and serves HTTP itself, so it sits in the same part of a stack where you might otherwise combine a web server with PHP-FPM. The docs list features such as Early Hints, worker mode, real-time capabilities, hot reloading, automatic HTTPS, HTTP/2 and HTTP/3. They also say it can be used as a Go library to embed PHP in an application using net/http.
A useful classification: FrankenPHP is an application server and distribution mechanism around PHP. PHP core remains the language and its extensions. FrankenPHP is also separate from CMS products and frameworks. The docs list skeletons for Symfony, Laravel, Drupal, WordPress and others, but a skeleton or integration does not make Drupal or WordPress part of FrankenPHP. Laravel and Symfony are framework projects with official worker-mode integrations. Drupal, WordPress, Joomla and TYPO3 are CMS products. FrankenPHP is also a different project from Swoole and Open Swoole. The supplied sources say nothing about those projects, so this guide makes no comparison with them.
Architecture and execution model
The docs say the Docker images are based on official PHP images, and that the builder image contains a compiled libphp. This means PHP is linked into the Caddy-based server binary rather than reached over FastCGI. The docs also describe standalone binaries, deb, rpm and apk packages, and a Homebrew package. The exact internal threading design is in the Internals page, which was not supplied, so it is not described here.
There are two request models.
- Classic mode: the model you know from PHP-FPM. Each request starts from a clean application state, runs your script, and ends. A static variable or global you set during one request is gone for the next.
- Worker mode: the application boots once and a long-lived worker handles many requests. The Docker page shows enabling it through the FRANKENPHP_CONFIG environment variable with the value worker ./public/index.php. The worker script API is on a page not supplied, so it is unknown here and no worker example is given.
This lesson stays with classic mode first.
State leakage in persistent workers
In a persistent worker, memory that survives between requests becomes your responsibility. Typical leaks are static properties, singletons, global variables, container services that cache the current user, open database transactions, and request data held in objects that outlive the request. The result can be one user seeing another user's data, or slow memory growth. Cleanup means resetting or discarding per-request state at the end of every request, avoiding mutable globals, closing or validating connections, and testing with several sequential requests from different users. Laravel and Symfony provide official worker-mode integrations, so follow those integration pages instead of writing your own reset logic. Their details were not in the supplied packets and are unknown here.
Practical use cases
- Replacing a separate web server plus PHP-FPM with a single server process, as the Migrating from Nginx/PHP-FPM page suggests.
- Serving a Symfony or Laravel application, optionally in worker mode.
- Trying CMS or framework skeletons the docs list, for example Drupal or WordPress, by following each project's own integration instructions.
- Shipping a container image or a self-contained binary.
- Embedding PHP in a Go program.
Installation approach
The docs offer several routes: an install script, standalone binaries, packages, Homebrew and Docker. Piping a downloaded script to a shell is a trust decision, so review it first or prefer packages or Docker. Binaries are described as containing PHP 8.5, and packages offer versions 82 to 85. Because this track targets PHP 8.4, the Docker route is the clearest fit: variants for PHP 8.2, 8.3, 8.4 and 8.5 are provided.
Docker tags follow the documented pattern: dunglas/frankenphp:FRANKENPHP-VERSION-phpPHP-VERSION-OS, where OS is trixie, bookworm or alpine. Debian variants are recommended. The example below builds a tag from that pattern using the major version 1. Check it against the tag browser before relying on it. The default Caddyfile in the image already reads environment variables, so the example sets SERVER_NAME and does not write its own Caddyfile. The assignment notes that the current Docker image keeps its Caddyfile at /etc/frankenphp/Caddyfile, but the supplied pages do not show that path. The hardening example uses /etc/caddy/Caddyfile for its own copy. Confirm the path and the directive syntax in the official configuration docs before overriding it.
Strengths
- Built on Caddy, with automatic HTTPS, HTTP/2 and HTTP/3 listed by the docs.
- Multiple delivery forms: Docker, packages, static binaries and a Go library.
- Classic mode keeps PHP-FPM-like request semantics, which makes migration easier to reason about.
- Official docs for Symfony, Laravel and WordPress integration, plus the listed skeletons.
- Docker images are rebuilt on new releases and daily when new official PHP images appear.
Limitations and unknowns
- Current FrankenPHP version, license, pricing and formal support policy: unknown from the supplied sources.
- The Docs list a Known issues page, and it was not supplied, so review it before adopting.
- On Alpine with Symfony, the docs say you may need to increase the default stack size.
- Worker mode brings the state risks above.
- This example needs Docker and network access to pull an image. It is not a browser exercise.
Deployment notes
For development, mount the source as a volume and use a tty for readable logs. For production, the docs say to remove the tty line and consider restart: always. Persist the Caddy data and config volumes so certificates survive. The image can run as a non-root user. Binding ports 80 and 443 then needs the CAP_NET_BIND_SERVICE capability. Alternatively, use an unprivileged port such as :8000 via SERVER_NAME and drop the capability. For stronger hardening, the docs describe distroless or Docker hardened bases, with the warning that they lack a shell and package manager. FastCGI is not used in this example and must never be exposed publicly. The example publishes its development port on 127.0.0.1 only, unlike the docs' sample that publishes ports 80 and 443.
Links
Read the official documentation and the Docker image page listed below for the current details.
Practical examples
These examples have not been run in a live environment yet. Commands and versions follow the official documentation linked on this page; check them before relying on them, and report a problem if one does not work.
Classic mode with Docker Compose on 127.0.0.1
A minimal classic-mode setup using the FrankenPHP Docker image with the image's default Caddyfile. SERVER_NAME selects an unprivileged port, and the container port is published only on the loopback interface. The PHP script shows that a static counter resets on every request, which is the classic request lifetime.
compose.yaml
services:
php:
# Tag built from the documented pattern; verify it in the Docker tag browser.
image: dunglas/frankenphp:1-php8.4-trixie
environment:
SERVER_NAME: ":8000"
ports:
- "127.0.0.1:8080:8000"
volumes:
- ./public:/app/public
- caddy_data:/data
- caddy_config:/config
tty: true
volumes:
caddy_data:
caddy_config:
public/index.php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
function nextHit(): int
{
static $count = 0;
return ++$count;
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
echo 'PHP version: ' . PHP_VERSION . "\n";
echo 'SAPI: ' . PHP_SAPI . "\n";
echo 'Path: ' . $path . "\n";
echo 'Static counter in this request: ' . nextHit() . "\n";
Install and run
docker compose up
curl http://127.0.0.1:8080/
curl http://127.0.0.1:8080/hello
docker compose downExpected behavior
Both curl calls should return plain text showing the PHP version and SAPI, the requested path, and a static counter of 1 each time, since classic mode starts each request from clean state.
Requires Docker, Docker Compose and network access to pull the image. The image tag is derived from the documented pattern and is unverified. Plain HTTP on a loopback port is for development only. Worker mode, HTTPS and a custom Caddyfile are not covered.