FrankenPHP in Production: What Changes When Symfony State Survives Requests

FrankenPHP keeps Symfony application state alive between requests. What that breaks in code built for classic request teardown.

A glowing PHP elephant silhouette stitched into a long-running runtime, with several requests flowing through one persistent application instance instead of separate request lifecycles

PHP has spent the better part of three decades tearing down request state between requests. In the classic PHP-FPM model, the worker process itself may be reused, but each request gets a fresh application lifecycle: fresh superglobals, fresh Symfony kernel, fresh container state, fresh request-scoped memory. The bug you wrote yesterday could not leak today because the application state you allocated was thrown away at the end of the request. PHP, as a language and as a runtime, was designed around that disposability. So was Symfony. So, more importantly, was your code.

FrankenPHP changes that contract. In worker mode, a long-running process boots the Symfony kernel once and then serves many requests against the same in-memory application. The cold-start cost disappears. Throughput per server goes up because the kernel boot, autoloader scan, and container instantiation that used to happen on every request now happen once per worker lifetime. Latency for the cheap requests, the ones that previously spent most of their time booting Symfony rather than doing work, collapses.

That is the marketing pitch, and it is true. What the marketing pitch does not tell you is that a Symfony codebase that has spent years being safe-by-accident, because PHP tore request state down at the end of every request, is suddenly running in an environment that remembers everything you did. Bugs that were invisible become production-shaping. Patterns that were idiomatic become foot-guns.

This essay is what we have learned from putting Symfony codebases into FrankenPHP worker mode and watching what actually changed. It is not a getting-started guide; the FrankenPHP docs cover that. It is the list of things that bite once the configuration is right and the process is running.

What worker mode actually does

Skip this section if you already know.

FrankenPHP runs PHP as a long-lived process and uses the frankenphp_handle_request function to receive HTTP requests inside a loop. Each iteration of that loop is a request. The kernel, the container, your services, your autoloader, your OPcache are all initialised once when the worker boots and then live as long as the worker does.

In a Symfony app, the entrypoint is a Runtime plus a worker script. Since Symfony 7.4, FrankenPHP worker mode is natively supported; older Symfony versions use runtime/frankenphp-symfony. The worker invokes Kernel::handle() repeatedly and resets the kernel between requests, which in turn calls the configured reset method on services tagged with kernel.reset (services implementing Symfony\Contracts\Service\ResetInterface are tagged automatically).

Two facts to internalise:

  1. The kernel and the container survive between requests. They are the same object instances.
  2. Symfony will reset services it knows about. It will not reset anything it does not know about.

Most of what follows is the second point, in different costumes.

The first thing that breaks: static state

Static properties, singleton state, function-level static variables, anything cached at the class or function level. All of it now persists between requests.

In disposable PHP, this code was fine:

PHP
final class SlugGenerator
{
    private static array $cache = [];

    public static function for(string $title): string
    {
        if (isset(self::$cache[$title])) {
            return self::$cache[$title];
        }

        $slug = \mb_strtolower(\preg_replace('/[^a-z0-9]+/i', '-', $title));
        self::$cache[$title] = $slug;

        return $slug;
    }
}

A request-scoped cache, harmless because classic PHP tore request state down. In a worker, this is a memory leak with no upper bound. Every unique title ever processed by this worker stays in self::$cache until the worker is restarted. After a day of traffic on a content-heavy site, the worker is holding tens of megabytes of slugs.

The fix is not to add cache invalidation. The fix is to recognise that “request-scoped static cache” is not a thing in long-running PHP. Either lift the cache into a real service tagged with kernel.reset and clear it in reset(), or scope the cache to a single call (build the array locally and return it).

The same applies to any library you depend on. Audit your composer.json. Anything that uses class-level statics or function-level statics for caching is a candidate. Common offenders we have seen: cached reflection metadata in older serializer libraries, validator caches that were never expected to live beyond a request, custom Twig extensions that memoise template lookups.

The second thing: Doctrine and stale entities

The Doctrine ORM EntityManager is the most important service that Symfony resets between requests when you wire it correctly. The doctrine/doctrine-bundle integration tags the EntityManager with kernel.reset, which clears the identity map and the unit of work. As long as you inject EntityManagerInterface through DI and use it normally, you are fine.

The problems start at the edges.

If you have a service that injects the EntityManager once in its constructor and caches entities in its own properties, those entities now outlive the request. They are detached from the new EntityManager after the reset, and any attempt to flush them produces EntityNotManagedException or, worse, silently writes nothing.

PHP
final class FeatureFlagService
{
    private array $flags = []; // populated lazily from DB

    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function isEnabled(string $name): bool
    {
        if ([] === $this->flags) {
            $this->flags = $this->em->getRepository(Flag::class)->findAll();
        }

        // ... lookup
    }
}

In disposable PHP, this was a perfectly reasonable lazy cache. In worker mode, request 1 populates $this->flags with entities managed by EM-instance-A. The kernel resets between requests. Request 2 still has $this->flags populated (the service instance survived) with entities that the new EM does not know about. The first time anyone tries to modify one of those flags and persist it, things break in confusing ways.

The fix is to implement ResetInterface on the service, or to reach for the flag data through a repository call every time and rely on a proper second-level cache below.

The other Doctrine wrinkle is connection liveness. The DBAL connection is held across requests. If the worker sits idle for a few minutes, the database server may have closed its side of the socket. The next request hits the connection, gets “MySQL server has gone away” or “Connection reset by peer,” and fails.

DoctrineBundle resets or clears initialized EntityManagers between requests, but that is not the same as a portable “ping every connection” guarantee. Custom DBAL connections, second databases, and connections used outside the standard EntityManager flow still need explicit thought. We have seen production incidents where a secondary read replica connection was held open for hours and failed the first request of every traffic spike because the connection was dead.

The robust fix is to close and reopen stale connections deliberately, or to wrap critical first-use queries in a bounded one-retry reconnect block. Doctrine DBAL exposes connection configuration and Connection::close(), but it does not give you one generic cross-driver auto-reconnect switch.

The third thing: services that hold handles

Beyond Doctrine, anything that opens a file handle, a socket, a stream, or a TCP connection at construction time is now holding that resource for the life of the worker.

The usual suspects:

  • HTTP clients with a long-lived connection pool. Symfony’s HttpClient is fine; it manages its own pool. A custom curl handle injected as a singleton is not.
  • Cache adapters that open a Redis connection. Symfony’s adapter handles reconnection. A raw \Redis instance you stashed in a service does not.
  • Logger handlers that open a file or a syslog socket. The Monolog stream handler is fine. Custom handlers that buffer in memory are a leak risk; see the next section.
  • Anything talking to RabbitMQ, Kafka, or another broker over a persistent connection.

The pattern: if a service holds a resource that has a timeout, an expiry, or a “the other end might disappear” failure mode, that service needs either to handle reconnection internally or to be tagged so that the kernel can reset it between requests.

Logger handlers and buffered output

Monolog is one of the most common sources of memory creep in worker mode. The default Symfony configuration uses a FingersCrossedHandler in production: the handler buffers log records in memory and flushes them only if an error-level record comes through. In long-running processes, Monolog’s in-memory state must be reset between jobs or requests; Symfony’s standard integration wires this for normal app logging, but custom loggers, processors, and handlers still need an audit.

In disposable PHP, the buffer was thrown away with the process. In worker mode, if the flush mechanism is misconfigured or if a custom processor accumulates records without flushing, the buffer grows forever.

This is one of the bugs you only see in worker mode. The symptom is memory rising linearly with request count, even on a “do nothing” health check endpoint. The fix is to verify that the FingersCrossedHandler is being reset between requests (the bundle integration handles this for the standard config) and to audit any custom processors or handlers for state that does not get cleared.

The general rule for logging in long-running PHP: assume any handler that buffers needs a reset hook, and verify it has one.

The Symfony runtime, RequestStack, TokenStorage

Symfony’s framework bundle does the right thing for its own request-scoped services. RequestStack, TokenStorage, the security context, the locale-aware translator, the active request locale, the active session, all of these are reset between requests by the kernel reset.

The trap is the code that depends on these services without going through them. The classic example: caching the current user as a property on your own service.

PHP
final class AuditLogger
{
    private ?User $currentUser = null;

    public function __construct(
        private TokenStorageInterface $tokens,
    ) {}

    public function log(string $action): void
    {
        $this->currentUser ??= $this->tokens->getToken()?->getUser();

        // ... write audit row
    }
}

In disposable PHP, lazy. In worker mode, request 1 sees user Alice, request 2 sees user Bob but AuditLogger still thinks the current user is Alice because the property survived the request. Audit rows are now wrong, and the bug only surfaces once two users hit the same worker.

The fix is to never cache the current user. Read it from TokenStorage on every call. The cost of the call is trivial; the cost of being wrong is enormous.

Apply the same logic to anything else that is logically request-scoped: the current locale, the current tenant, the current correlation ID. Read it through the framework, do not cache it in your own service.

Memory: tiny leaks become production-shaping

In disposable PHP, a 200 KB leak per request was invisible because request state was torn down at the end. In worker mode, a 200 KB leak per request becomes a problem at request five thousand: 1 GB of leaked memory in a single worker. The worker hits its memory limit and gets restarted. If you have not configured a memory limit, the worker grows until the OS kills it, taking in-flight requests with it.

Two operational habits make this manageable.

First, set a restart limit on the worker (FrankenPHP’s experimental max_requests directive, or MAX_REQUESTS in a custom worker loop) and keep PHP’s memory_limit realistic so that workers cycle predictably. Even a healthy worker should be cycled periodically, not because anything is wrong but because cycling is cheap insurance.

Second, monitor RSS per worker. You want a flat line, not a slope. If memory grows linearly with request count on a do-nothing endpoint, you have a leak; find it before it finds you.

The tooling we reach for to find leaks is the same as in any long-running PHP context: profiler snapshots under a replayed request mix, explicit memory_get_usage() checkpoints around suspected services, and disabling middleware one at a time until the slope flattens.

Sessions, cookies, and request isolation

Symfony’s session handling is request-scoped through RequestStack, so the framework handles isolation correctly. The trap, again, is anything that reads the session outside the framework’s lifecycle, or anything that uses PHP’s native $_SESSION superglobal directly.

FrankenPHP resets the request superglobals ($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER, $_REQUEST) on each handled request, but its docs explicitly call out that $_ENV is not reset. Session state has its own lifecycle, and using $_SESSION directly bypasses Symfony’s session abstraction. The fix is the same as for the current-user trap: use the framework’s session object, do not reach into $_SESSION.

The same discipline applies to $_COOKIE, $_REQUEST, $_SERVER, and $_ENV: the proper way to read request data is through the Request object, and the proper way to read process configuration is through your configuration layer. Direct superglobal access makes request isolation harder to reason about.

OPcache, file watching, and the dev loop

In production, you want OPcache enabled with validate_timestamps=0; a preload script can help when you have measured that it matters. The worker boots once, OPcache fills, and subsequent requests get compiled bytecode from memory. The larger worker-mode win is separate: the Symfony kernel and container do not need to be rebuilt for every request.

In development, this would mean that changes to PHP files are invisible until the worker restarts. FrankenPHP’s --watch flag handles this: it watches the source tree and restarts workers on change. Configure it to ignore var/, node_modules/, and anything else that changes frequently for unrelated reasons, otherwise you will spend the day with workers restarting on every cache write.

Twig templates may reload through Twig’s dev settings, but PHP code and Symfony configuration changes need the worker to restart because the running kernel and container are already in memory. If you edit services.yaml, the worker needs to restart.

The dev experience with --watch is close to the disposable-PHP experience as long as you have the watcher configured correctly. The first time you edit a service definition and your change does not appear, check whether the watcher is restarting the worker.

Deploys: rolling restart, not in-place reload

A worker is a long-running process holding compiled bytecode in memory. Deploying new code means restarting the worker, otherwise the worker keeps serving the old version.

The two patterns we use:

Rolling restart. New workers are started with the new code; old workers are sent SIGTERM and given a grace period to finish in-flight requests; the load balancer drains traffic. This is the safe pattern and works the same way you would deploy any long-running service.

Graceful reload. FrankenPHP supports sending workers a signal to reload (the exact mechanism depends on whether you are running Caddy-managed FrankenPHP or standalone). Inside the worker, you check at the top of the request loop whether a reload is pending; if so, you break out of the loop and let the worker exit cleanly so the supervisor restarts it with new code.

The pattern to avoid is OPcache reset without restarting the worker. You can clear OPcache from inside the worker, but the worker itself still holds references to the old class definitions in its container. The result is a half-reloaded application that mostly works until it spectacularly does not.

When not to switch

FrankenPHP is not a free upgrade. The decision matrix we use with clients:

Switch when:

  • The application is CPU-bound on cold starts (each request spends meaningful time booting the kernel before doing work).
  • Throughput per server is your scaling constraint and you have headroom on memory.
  • The team has the ops maturity to monitor memory per worker and respond to leaks.
  • The codebase is recent (Symfony 6.4+, ideally 7.x) and free of the worst static-state habits.

Do not switch yet when:

  • The application is bottlenecked on the database, not on PHP. Worker mode does not make slow queries faster.
  • The codebase has heavy use of global state, custom singletons, or static caches that nobody has audited.
  • The deployment story is ad-hoc and the team is not equipped to handle long-running processes.
  • The traffic is so low that cold starts are not the bottleneck (a small internal tool does not need worker mode).

The honest version: most Symfony applications under sustained traffic benefit from worker mode, but the audit and cleanup work to get there safely is not trivial. Plan for it.

The migration playbook

A condensed version of how we approach this in client engagements:

  1. Audit static state. Grep the codebase for static properties, static function cache patterns, and singleton classes. Lift everything that is supposed to be request-scoped into proper services with reset() methods.
  2. Audit service state. Any service that caches an entity, a user, a locale, or any other request-scoped value as a property is a candidate for breakage. Implement ResetInterface or remove the cache.
  3. Audit superglobal access. Anywhere your code touches $_SESSION, $_COOKIE, $_REQUEST, $_SERVER directly is a candidate for bleed between requests. Route through the framework.
  4. Audit connection-holding services. Anything that opens a socket, a file handle, or a database connection at construction needs an explicit reconnection strategy.
  5. Wire up monitoring. RSS per worker, requests per worker, time since last restart. Without these you cannot tell whether worker mode is working.
  6. Pick a memory limit and a max-request count. Workers should cycle predictably, not unpredictably.
  7. Deploy with rolling restart. Never reload in place.
  8. Run for a week with both worker mode and classic mode behind the same load balancer. Compare error rates, memory, latency. Switch fully only when worker mode is at least as healthy on every metric.

The audit is the work. The configuration is the easy part.


If you are looking at a Symfony codebase and trying to decide whether worker mode is a good fit, our scaling and performance engagement includes a long-running-PHP audit: static-state grep with a triage list, service-state review for reset compliance, and a runbook for the migration. The first week of running on FrankenPHP is the dangerous one; we make sure your team has the monitoring and the deploy story ready before you flip the switch.

References

Ready to Fix Your Architecture?

Book a free 30-minute call with Silas. No sales pitch, just a direct conversation about your challenges.

Typically responds within 24 hours.

Book a Free Call