PHP compatibility and project lifecycle
The Docker docs list image variants for PHP 8.2, 8.3, 8.4 and 8.5. This track uses PHP 8.4 with 8.5 planned, so pin a PHP 8.4 tag. The exact tag, the FrankenPHP version and any support policy are unknown from the supplied sources.
Official sources checked 2026-10-22. Browser lessons on this site run PHP 8.5.
What this project is
FrankenPHP is a PHP application server. The supplied Docker documentation says it is built on top of Caddy and that its images are based on official PHP images. Worker mode is an execution mode of FrankenPHP, not a separate PHP implementation. The guide is about how to boot a PHP application once and keep it in memory between HTTP requests.
It relies only on the two official documentation pages linked below. The sources do not state a license, a current FrankenPHP version, a support policy or any performance measurements, so this guide makes no claims about them. The lifecycle is recorded as unknown.
Classification and what it is not
FrankenPHP is a server that embeds PHP, and worker mode is one way to run it. That is different from several things people confuse it with:
- Async libraries and framework adapters are not standalone runtimes. Laravel Octane and Symfony's worker integration are adapters that sit on top of a server. The worker page points to separate Symfony and Laravel Octane documentation for them. Follow those pages for framework setup instead of copying the plain script in this guide.
- Historical alternative interpreters are not covered here. HHVM targets Hack and must not be assumed to run modern PHP.
- Swoole and Open Swoole are separate projects and are not described in the supplied sources. Do not mix their APIs with FrankenPHP.
- PHP core, a framework component and a CMS product are different layers. Drupal core, for example, is not Drupal CMS. FrankenPHP only provides the execution layer underneath whichever application you run.
Architecture and execution model
The docs list classic mode and worker mode as separate topics. Only the worker page was supplied, so the details of classic mode are unknown from the sources. In the usual classic request lifetime, application code starts for each request and its state is discarded afterwards. Worker mode changes that: the script boots your app once, then repeatedly calls frankenphp_handle_request with a handler callback.
According to the worker page:
- Superglobals such as GET, POST, COOKIE, FILES, SERVER and REQUEST are reset between requests. Before the first call they hold values bound to the worker script itself. During a call they hold the request's values.
- ENV is currently not reset between requests, so never keep request-specific or sensitive data there.
- set_exception_handler runs only when the worker script ends, so catch exceptions inside the handler.
- By default 2 workers per CPU start, and you can append a count to the worker setting.
- A worker that exits with a non-zero code is restarted with exponential backoff. Repeated quick failures end in a too many consecutive failures crash, which max_consecutive_failures in the Caddyfile can tune.
- With worker mode enabled, the default num_threads is 1 + 2x the number of available CPUs. If you set both num_threads and num, num_threads must be strictly greater.
State leakage and cleanup
Because the PHP process stays alive, the following persists across requests on the same worker thread, according to the docs:
- static variables in functions and methods
- class static properties
- global variables in the worker script
- in-memory caches held outside the handler
- runtime engine settings changed with ini_set, stream_context_set_default, date_default_timezone_set, chdir, stream_wrapper_register, set_error_handler or set_exception_handler
The docs call this by design, and it is the source of the speed benefit. It also creates risk. A user's data stored in a static property can reach the next user's request. Credentials placed in the default stream context stay active, so pass an explicit context to each call instead.
Practical cleanup habits:
- Keep request-scoped data in local variables or in objects created inside the handler.
- Reset any of your own services that hold request data after each request. The docs note that Symfony services with request state should implement ResetInterface.
- Call gc_collect_cycles after each request, as the documented loop does.
- Because many libraries and older code leak memory, use the documented MAX_REQUESTS pattern to restart the worker after a set number of requests.
- Restart all workers gracefully through the Caddy admin API restart endpoint when it is enabled, or use the watch option during development.
Do not take the Symfony or Laravel Octane statement about resetting most state as permission to skip review. Your own services are still your responsibility.
Practical use cases
Worker mode suits applications whose boot cost is significant and whose code is written, or audited, for long-lived processes. Framework apps that have a documented worker integration are the clearest fit. Legacy code that relies on globals, statics or process-wide settings needs careful review first. The docs also include a Migrating from Nginx/PHP-FPM page, whose content was not supplied here.
Installation approach
The Docker page documents the dunglas/frankenphp image. It says Debian and Alpine variants exist and that variants for PHP 8.2, 8.3, 8.4 and 8.5 are provided. Tags follow the pattern frankenphp version, then php version, then operating system. Because this track targets PHP 8.4, choose a tag that carries the PHP 8.4 variant. This guide does not guess an exact tag, and the untagged image used below does not tell you which PHP version you get. The worker page also documents a standalone binary with a php-server command and a worker option, but the supplied sources do not say how to install that binary.
To enable worker mode in Docker, set the FRANKENPHP_CONFIG environment variable to the word worker followed by the script path. You can bake it into a Dockerfile with an ENV line. The image ships a default Caddyfile with useful environment variables, and the SERVER_NAME variable can select an unprivileged port such as 8000.
The example
The example below is a small worker with no Composer dependencies. It shows a counter that persists because of a static variable, and a per-request notes list that is reset after every request. It binds the published port to 127.0.0.1 only. It is a runtime example that needs Docker, so it is not a browser exercise.
Strengths
- The application boots once and stays in memory.
- The loop is explicit, so reset logic is easy to place.
- It builds on Caddy, so Caddy modules can be added through the documented xcaddy build.
- The docs cover non-root operation and hardened base images.
- Worker failures are restarted with backoff.
Limitations
- State leakage is a real hazard and, per the docs, ENV is not reset.
- Many libraries and older code leak memory, hence the restart workaround.
- Support status, licensing and exact version compatibility are unknown from the supplied sources.
- Classic mode details were not supplied.
- Each worker thread keeps its own persistent state, so counters and caches differ between threads.
Deployment notes
The Docker page suggests a restart policy for production, dropping the tty option so logs are JSON, and keeping the Caddy data and config volumes for certificates. Pin the image tag deliberately rather than relying on an untagged image. Never expose Caddy admin endpoints publicly. Treat everything here as a starting point for review, not a production recipe.
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.
Minimal FrankenPHP worker loop with Docker Compose
A dependency-free worker script run through the dunglas/frankenphp image with FRANKENPHP_CONFIG. It shows a static counter that persists across requests, a static notes list that is reset after each request, and a MAX_REQUESTS restart limit. The port is bound to 127.0.0.1 only.
compose.yaml
services:
php:
image: dunglas/frankenphp
environment:
FRANKENPHP_CONFIG: 'worker ./public/index.php'
SERVER_NAME: ':8000'
MAX_REQUESTS: '500'
ports:
- '127.0.0.1:8000:8000'
volumes:
- ./:/app
- caddy_data:/data
- caddy_config:/config
tty: true
volumes:
caddy_data:
caddy_config:
public/index.php
declare(strict_types=1);
final class RequestScope
{
/** @var list<string> */
public static array $notes = [];
public static function reset(): void
{
self::$notes = [];
}
}
function persistentHits(): int
{
static $hits = 0;
return ++$hits;
}
$handler = static function (): void {
try {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
RequestScope::$notes[] = 'path=' . $path;
header('Content-Type: text/plain; charset=utf-8');
echo 'PHP ' . PHP_VERSION . "\n";
echo 'persistent hits on this worker thread: ' . persistentHits() . "\n";
echo 'request notes (reset after each request): ' . implode(', ', RequestScope::$notes) . "\n";
} catch (\Throwable $exception) {
http_response_code(500);
error_log($exception->getMessage());
echo "Internal error\n";
}
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
$keepRunning = \frankenphp_handle_request($handler);
// Reset request-scoped state so it cannot leak into the next request.
RequestScope::reset();
gc_collect_cycles();
if (!$keepRunning) {
break;
}
}
Install and run
docker compose up
curl http://127.0.0.1:8000/hello
curl http://127.0.0.1:8000/again
docker compose downExpected behavior
Each response prints the PHP version and a request notes line that lists only the current path, because the notes are reset after every request. The persistent hits number grows across requests on the same worker thread. With several worker threads, the counter can differ between responses. After 500 handled requests, the worker script ends and is started again.
The untagged image does not tell you the PHP version, so pin a PHP 8.4 tag yourself. The default document root and Caddyfile behavior of the image are not described in the supplied sources, so verify them. Worker state is per thread. It is a development example, not a hardened production setup. It needs Docker and network access to pull the image, and it is not a browser exercise.