FrankenPHP in Production: Modern PHP Deployment Without PHP-FPM

#FrankenPHP production

Founder & Lead Developer

Expert in software development and legacy code optimization

LinkedIn

Short answer: Yes, FrankenPHP is production-ready for Symfony applications. It replaces Nginx and PHP-FPM with one Caddy-based binary, and since Symfony 7.4 its worker mode is supported natively, without an extra runtime package. The safe way in is to run it in classic mode first, audit services that keep state between requests, and only then enable worker mode. Keep the image current: the 2026 releases included several security fixes.

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."

What Changed Since 2025

FrankenPHP has moved quickly, and a few changes affect how you should set it up today:

  • It is now part of the PHP project. In May 2025 the PHP Foundation announced official support for FrankenPHP and moved the repository to the php GitHub organization. The maintainers stayed the same. For teams that hesitated to bet their runtime on a small independent project, that removes much of the risk.
  • Symfony 7.4 supports worker mode natively. Since Symfony 7.4 (November 2025) you no longer need the runtime/frankenphp-symfony package; the Symfony Runtime detects FrankenPHP's worker mode on its own. On Symfony 6.4 to 7.3 you still install that package.
  • Security releases are frequent. FrankenPHP shipped several security fixes in 2026, among them CVE-2026-24894 in 1.11.2 ($_SESSION data leaking between requests in worker mode) and CVE-2026-45062 in 1.12.3 (a path-splitting bug that could execute non-.php files as PHP). Treat the FrankenPHP version like the PHP version: pin it, and update it on a schedule.
  • Worker restarts after a set number of requests can now be configured with a max_requests setting (see the memory section below).
  • Windows is supported natively since March 2026. That matters for local development on Windows machines, not for production.

Worker Mode and What It Means for Symfony

For a typical Symfony 6.4 or 7.x application, enabling worker mode is a few lines of configuration 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']);
};

This is the standard public/index.php that Symfony Flex generates, so there is usually nothing to change. On Symfony 7.4 and later, the Symfony Runtime detects that FrankenPHP started the script as a worker and runs the request loop itself. On Symfony 6.4 to 7.3, install runtime/frankenphp-symfony and set APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime. From the application's perspective, controllers and services look identical to FPM. The difference is what survives between requests.

For a Symfony API serving JSON, the performance difference can be large. The container, routing, and Doctrine metadata are built once instead of on every request, and on small endpoints that boot is a large share of the total response time. How much you gain depends on the application: endpoints dominated by database I/O see smaller gains, because the database is the bottleneck either way. Measure it on your own endpoints with a load test before and after, and compare the p95 latency, not only the average.

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 (DoctrineBundle resets its managers through kernel.reset, but entity managers you build yourself do not get that treatment), 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-php8.4 AS base

# The FrankenPHP image does not include Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN install-php-extensions intl opcache pdo_pgsql zip

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
}

:80 {
    root * /app/public
    encode zstd br gzip
    php_server
}

Pin the image to a specific release (for example 1.12-php8.4) once the setup works, so a rebuild does not silently move you to a new version. This phase should be a no-op functionally. Latency might tick down slightly 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.

No static analysis tool finds all of these reliably, so add a functional test that behaves like a worker. Symfony's KernelBrowser can keep one kernel across requests with $client->disableReboot(). Log in as user A, make a request, log in as user B, make the same request, and assert that nothing from user A shows up. Tenant context, locale, and cached permissions are the usual suspects. Running this test in CI before enabling worker mode is cheap insurance.

Phase 3: Enable Worker Mode

The final phase enables worker mode through Caddyfile configuration:

{
    frankenphp
}

:80 {
    root * /app/public
    encode zstd br gzip
    php_server {
        worker {
            file /app/public/index.php
            num 4
        }
    }
}

On Symfony 6.4 to 7.3, add env APP_RUNTIME Runtime\\FrankenPhpSymfony\\Runtime inside the worker block. On 7.4 and later it is not needed.

If you leave out num, FrankenPHP starts twice as many workers as there are CPUs. Start with that default and tune it against a load test. Each worker holds a full Symfony kernel in memory, so pod sizing and autoscaling rules need to be re-tuned: what used to be CPU-bound is now more often 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 to restart workers after a fixed number of requests. Current FrankenPHP releases (1.12.3 and later) provide a max_requests setting in the global frankenphp block, which applies to all PHP threads:

{
    frankenphp {
        max_requests 1000
    }
}

On Symfony 6.4 to 7.3, the runtime/frankenphp-symfony package already restarts each worker after 500 requests by default; the FRANKENPHP_LOOP_MAX environment variable changes that number. When you remove the package on Symfony 7.4, do not rely on that default any more: set max_requests explicitly so the restart behaviour is visible in your own configuration. A value in the low thousands is a reasonable starting point; lower it if memory still grows. A restart costs one kernel boot, which is small next to the cost of an out-of-memory kill. 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. On a server where you deploy in place, restart the workers gracefully through the Caddy admin API with curl -X POST http://localhost:2019/frankenphp/workers/restart. In development, the watch option in the worker block restarts workers when PHP files change, so you do not have to restart by hand.

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 behave differently when one PHP process serves many requests. Check your APM vendor's documentation for FrankenPHP worker mode support, and verify on day one that each trace covers exactly one request. Finding out during the first incident that all requests from one worker end up in a single trace is expensive. FrankenPHP also exposes Prometheus metrics for threads and workers through Caddy, which are worth adding to your dashboards next to container memory.

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. Search each bundle's issue tracker for "FrankenPHP" or "worker mode" before enabling it. That short check prevents most of the "why is the second request always 500?" debugging sessions.

FrankenPHP vs PHP-FPM at a Glance

Nginx + PHP-FPMFrankenPHP classic modeFrankenPHP worker mode
Processes to runNginx and PHP-FPMOne binaryOne binary
TLS and HTTP/3Configured in NginxAutomatic via CaddyAutomatic via Caddy
Framework bootEvery requestEvery requestOnce per worker
State between requestsNoneNoneServices and static properties persist
Deploying new codeNext request picks it upNext request picks it upWorkers must restart
Main riskConfiguration of two componentsFew; mostly $_SERVER differencesLeaked state and memory growth

The table shows why the phased migration works: classic mode gives you the operational simplification at almost no risk, and worker mode is a separate decision about performance.

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.