FrankenPHP and Worker Mode
A worker boots the kernel once and loops over requests, which is faster and lets service state leak between them unless it is reset.
#[AutoconfigureTag('kernel.reset', ['method' => 'reset'])]In the usual PHP setup a request arrives, the web server hands it to a process, that process includes public/index.php, builds the kernel, answers, and exits. Everything is discarded. Nothing can leak between requests, and you pay the boot every time.
A worker inverts that. One long-running process builds the kernel once and then loops: take a request, answer it, take the next. FrankenPHP is a PHP server that can run your front controller this way. When the server environment contains a truthy FRANKENPHP_WORKER, Symfony's Runtime component selects FrankenPhpWorkerRunner, which calls frankenphp_handle_request() in a loop. Your public/index.php does not change.
What leaks
Anything a service keeps in a property survives into the next request: a cached current user, a locale derived from one request's headers, an array that grows, a buffered log, a static property anywhere in your code. Each is a bug that only appears on the second request handled by that process, which is why it never shows up in development behind a single-request server.
Resetting
On every main request except the first, Symfony calls a method on each service carrying the kernel.reset tag. The convention is to implement Symfony\Contracts\Service\ResetInterface and name the method reset(). Symfony's own services are tagged this way; yours are your responsibility.
Two safety valves: FRANKENPHP_RESET_KERNEL=1, introduced in Symfony 8.1, clones the application after each request so the next one starts clean, at a real cost in throughput; and FRANKENPHP_LOOP_MAX, 500 by default, restarts the worker after that many requests.
Example
<?php
// src/Pricing/ExchangeRates.php
declare(strict_types=1);
namespace App\Pricing;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Contracts\Service\ResetInterface;
// The attribute is shorthand for a kernel.reset tag in config/services.yaml;
// the YAML example below is the same wiring.
#[AutoconfigureTag('kernel.reset', ['method' => 'reset'])]
final class ExchangeRates implements ResetInterface
{
/** @var array<string, float> */
private array $rates = [];
public function __construct(private readonly RateProvider $provider)
{
}
public function rate(string $currency): float
{
return $this->rates[$currency] ??= $this->provider->fetch($currency);
}
public function reset(): void
{
// Called between requests in worker mode. It must leave the service
// usable, not half-built: empty the cache, keep the dependencies.
$this->rates = [];
}
}
// Within one request:
// $rates->rate('EUR'); // 1 call to RateProvider
// $rates->rate('EUR'); // 0 calls, answered from $rates
//
// Without reset(), request 2 in the same worker also answers from $rates -
// with a rate fetched whenever that process happened to start.When to use it
- Serve a high-traffic endpoint without paying the kernel boot on every request.
- Track down a bug that only shows on the second request to the same process.
- Decide between auditing every service for reset and paying for a kernel clone.
- Cap worker lifetime so a slow memory leak cannot take a pod down.
More examples
The same tag in YAML
Identical to the AutoconfigureTag attribute on the class, and worth knowing because the tag is what you grep for when auditing an application for worker safety. Use php bin/console debug:container --tag=kernel.reset to list every service that will be reset; anything holding per-request state and missing from that list is a leak.
# config/services.yaml
services:
App\Pricing\ExchangeRates:
tags:
- { name: kernel.reset, method: reset }public/index.php, unchanged except for a maintenance response
The front controller returns a callable; the Runtime component decides how to run whatever that callable returns. Return a kernel and you get the request loop, return a Response and every request in the loop gets that response. This is how a maintenance mode stays fast and needs no application boot at all.
<?php
// public/index.php
use App\Kernel;
use Symfony\Component\HttpFoundation\Response;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
// Returning a Response from the worker loop is supported from
// Symfony 8.1: the loop keeps serving this without booting the kernel.
if ($context['APP_MAINTENANCE'] ?? false) {
return new Response('Service Unavailable', 503);
}
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};The environment a worker runs with
Start with the first three lines and no reset flag, then load test and watch for bugs that appear only after the first request. Turning FRANKENPHP_RESET_KERNEL on is the safe default while you are still finding leaks; turning it off later is where most of the speed comes from, so keep the restart limit as a backstop against slow leaks you have not found.
# Environment for the FrankenPHP container (docker compose, a systemd unit)
# Run the front controller as a worker script.
FRANKENPHP_CONFIG="worker ./public/index.php"
# Symfony's Runtime component checks this to pick FrankenPhpWorkerRunner
# instead of the one-request-per-process runner.
FRANKENPHP_WORKER=1
# Restart each worker after this many requests. 500 is Symfony's default.
# FrankenPHP has its own equivalent, MAX_REQUESTS.
FRANKENPHP_LOOP_MAX=500
# Symfony 8.1 and later. Clones the application after each request so state
# cannot leak. Use it when auditing every service is not realistic, and
# measure the cost: each request starts from a freshly booted kernel.
FRANKENPHP_RESET_KERNEL=1
Discussion