FrankenPHP in Production: Modern PHP Deployment Without PHP-FPM
The first time you put a Symfony application behind FrankenPHP in production and the request graph in Grafana flattens out, it feels like cheating. The same code, the same database, the same Redis cluster — the only thing that changed is that you removed the Nginx + PHP-FPM sandwich and replaced it with a single Go binary that speaks HTTP/3, holds your application in memory between requests, and ships your assets as static files from the same process. P95 latencies drop, the container image shrinks, and the whole "what process is supposed to be running where" conversation stops happening at deploy time.
That is the optimistic version of the FrankenPHP production story. The realistic version has a few more chapters: worker-mode applications behave differently from request-per-process applications, long-lived PHP workers leak memory in ways that PHP-FPM hid for two decades, and a few Symfony bundles still assume a fresh kernel boot per request. None of these are deal-breakers, but they are the difference between a successful migration and a 2 a.m. rollback. This post walks through what FrankenPHP actually changes architecturally, when worker mode pays off for Symfony applications, the migration path from a traditional stack, and the operational caveats worth understanding before you ship.
What FrankenPHP Actually Changes
FrankenPHP is a server written in Go that embeds the PHP interpreter directly. It is built on top of Caddy — the same web server that ships automatic HTTPS and HTTP/3 — and it can act as a drop-in replacement for the Nginx + PHP-FPM combination that has powered most PHP deployments since 2010.
The architectural shift is real. In a traditional stack, an HTTP request travels from the load balancer to Nginx, then over a Unix socket or TCP to a PHP-FPM pool, where a worker process is selected, your application bootstraps, processes the request, sends a response back through FPM and Nginx, and tears down. The FastCGI protocol is doing real work on every request. Static assets are served by Nginx, which means two pieces of configuration — one for the framework, one for the file routes — and two binaries to upgrade independently.
FrankenPHP collapses that pipeline. The same binary terminates TLS, serves static files, and dispatches dynamic requests to embedded PHP. There is no socket, no FastCGI translation, and no second daemon to supervise. Caddy's TLS automation works out of the box, including for Let's Encrypt and ZeroSSL. HTTP/3 (QUIC) is enabled by adding a single line of configuration. The single-binary deployment story is genuinely simpler — one process, one config file, one health check.
The other change, and the more interesting one for performance, is worker mode. In classic mode, FrankenPHP behaves like FPM: each request boots the framework, runs the controller, and tears the kernel down. In worker mode, FrankenPHP boots your application once, holds it in memory, and dispatches every incoming request to the warm process. For a Symfony application, this means the container, the route cache, the Doctrine metadata, and every service instance survive between requests. The cost of bootstrapping moves from "every request" to "every deploy."
Worker Mode and What It Means for Symfony
The Symfony team officially supports FrankenPHP worker mode through a small runtime adapter, and the integration has matured significantly through 2025 and into early 2026. For a typical Symfony 6.4 or 7.x application, enabling worker mode is a few lines of code plus a careful audit of services that hold per-request state.
A minimal worker entry point looks like this:
<?php
// public/index.php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
The runtime knows it is in worker mode through the FRANKENPHP_CONFIG environment variable, and the runtime/frankenphp-symfony package handles the request dispatch loop. From the application's perspective, controllers and services look identical to FPM. The difference is what survives between requests.
For a real Symfony API serving JSON, the performance delta is substantial. On a typical CRUD endpoint with Doctrine and a Twig-free response path, worker mode usually delivers 3–5x more throughput than FPM, with P95 latency dropping by 40–60%. The numbers vary with the application — endpoints dominated by database I/O see smaller gains because the database is the bottleneck — but the Symfony framework boot itself can easily account for 30 ms of every request, and worker mode reclaims it.
The trade-off is that anything you stuff into a static property, a service constructor, or the container's compiled state lives forever. That is fine for stateless services and configuration, and it is the entire reason the speedup happens. It is not fine for a Doctrine EntityManager that has accumulated 50,000 managed entities over an hour of traffic, or a logger that buffered every request body for "debugging," or a custom service that cached the current user's permissions and forgot to clear them.
The concrete Symfony services that need attention are predictable: the entity manager (Doctrine ships a clearEntityManagerSubscriber that you should enable), the request stack (it already resets correctly), Monolog handlers that buffer (use the FingersCrossedHandler carefully), and any custom service that holds state across RequestEvent lifecycle hooks. The kernel.reset tag exists precisely for this — services tagged with it get their state cleared between requests, and that contract becomes load-bearing in worker mode.
Migrating from Nginx + PHP-FPM
A FrankenPHP migration on a production Symfony application is best treated as three sequential phases rather than one big switch. Each phase is independently reversible, which keeps the blast radius small.
Phase 1: Run FrankenPHP in Classic Mode
The first phase replaces Nginx and PHP-FPM with FrankenPHP in classic (request-per-process) mode. No application changes are required. The Dockerfile gets simpler:
FROM dunglas/frankenphp:1.4-php8.3 AS base
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-progress
COPY . .
RUN composer install --no-dev --optimize-autoloader \
&& bin/console cache:warmup --env=prod
ENV SERVER_NAME=:80 \
APP_ENV=prod
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
A minimal Caddyfile:
{
frankenphp
order php_server before file_server
}
:80 {
root * /app/public
encode zstd br gzip
php_server
}
This phase should be a no-op functionally. Latency might tick down 5–10% from removing the FastCGI hop, and the container image is smaller because there is no separate Nginx layer. Run this in production for a few days. If anything is going to break — usually a hard-coded $_SERVER value or a Nginx-specific header trick — it will surface here, in a context where rolling back is trivial.
Phase 2: Audit and Tag Services for Reset
Before flipping worker mode on, walk through bin/console debug:container --tag=kernel.reset and confirm that every service holding mutable state is either reset between requests or genuinely meant to be cached. The audit tends to produce a short list: a session-related decorator, an audit log buffer, a tenant context resolver. Tagging these with kernel.reset and implementing the reset() method takes an afternoon and is worth doing carefully.
PHPStan with the kernel.reset rule catches the most common mistakes (a service that mutates state but does not declare a reset hook). Running it as a CI check before enabling worker mode is the cheapest possible insurance.
Phase 3: Enable Worker Mode
The final phase enables worker mode through Caddyfile configuration:
{
frankenphp {
worker {
file /app/public/index.php
num 4
env APP_RUNTIME Runtime\\FrankenPhpSymfony\\Runtime
}
}
}
:80 {
root * /app/public
encode zstd br gzip
php_server
}
The num 4 directive matches the number of CPU cores available to the container. With four warm Symfony kernels per pod, a typical 2-vCPU production container handles roughly the throughput of an 8-vCPU pod running FPM. Pod sizing and autoscaling rules need to be re-tuned — what used to be CPU-bound is now memory-bound, and the horizontal scaling decision changes accordingly.
Operational Caveats the Hype Posts Skip
Worker mode is a different operational model, and a few caveats deserve attention before they become production incidents.
Memory Growth Is Real
PHP processes were never designed to live forever. A worker mode kernel that runs millions of requests will accumulate memory from a dozen sources: opcache compaction, vendor libraries that maintain their own caches, occasional leaks in extensions like Imagick or gRPC, and Doctrine's identity map if anything bypasses the reset hook. None of these are catastrophic on their own, but they compound.
The mitigation is the max_requests directive, which restarts a worker after a fixed number of requests:
worker {
file /app/public/index.php
num 4
max_requests 1000
}
A value between 500 and 5,000 works for most applications. Restarts are graceful — in-flight requests finish on the existing worker while a new one boots — so the latency impact is invisible. Pair this with a Prometheus alert on container memory usage trending up over multiple days, which usually catches the slow leaks that even max_requests cannot resolve.
Deployment Becomes a Real Event
In FPM, deploying meant rsyncing files and reloading the FPM master. The new code took effect on the next request. With worker mode, file changes are not picked up until the workers restart. The deploy mechanic is therefore a graceful FrankenPHP restart, which Caddy supports natively (SIGUSR1).
For containerized deployments, the standard rolling update pattern works fine — Kubernetes spins up a new pod, drains traffic from the old one, and shuts it down. Just make sure the readiness probe waits for the worker pool to fully boot, which on a moderately complex Symfony application can take 2–4 seconds.
Observability Needs to Cover the Process
Application Performance Monitoring tools that hooked into FPM's per-request lifecycle need their FrankenPHP integrations updated. New Relic, Datadog, and Tideways all ship FrankenPHP-aware agents as of 2025, but verifying that traces actually carry the request boundary correctly is worth doing on day one rather than during the first incident.
Some Bundles Are Still Catching Up
Most mainstream Symfony bundles handle worker mode correctly today. A few — particularly older payment integrations, custom CMS bundles, and anything that registers global PHP error handlers — assume a fresh process per request and need either a patch or a service tag. The symfony/runtime documentation maintains a list of known-good and known-bad bundle versions. Auditing the dependency graph against that list before enabling worker mode prevents the "why is the second request always 500?" debugging session.
When FrankenPHP Pays Off — and When It Does Not
For a high-traffic Symfony API, a SaaS application with predictable request shapes, or anything that spends real time in framework boot, FrankenPHP with worker mode is one of the largest performance wins available without rewriting code. The migration is bounded, the rollback is trivial during the classic-mode phase, and the operational simplification of running one binary instead of two is genuinely valuable for small teams.
For a small marketing site doing a few hundred requests an hour, the gains are mostly cosmetic. For a Symfony application built on bundles that have not been audited for worker mode, the cleanup work needed before enabling worker mode is real engineering effort. And for teams that depend heavily on per-request PHP error handlers or process-level state, the model fits awkwardly.
The honest framing is that FrankenPHP is a serious modernization for the right kind of application, not a universal upgrade. The teams that get the most out of it are usually the ones that already invested in clean service design, kept their Symfony version current, and have observability in place to validate the change.
If your team is sizing up a FrankenPHP migration — or carrying a Symfony application that has not seen real attention to its deployment stack in years — that is exactly the kind of work Wolf-Tech runs for European SaaS and product companies. A focused code quality audit usually identifies the worker-mode blockers before the migration starts, and the legacy code optimization and tech stack strategy work turns "we should try FrankenPHP" into a measured engineering rollout. Contact us at hello@wolf-tech.io or visit wolf-tech.io for a free consultation on whether FrankenPHP is the right next step for your stack.

