FrankenPHP and PHP-FPM
The two ways to serve Symfony in a container: a single FrankenPHP binary with worker mode, or nginx in front of PHP-FPM.
FRANKENPHP_CONFIG="worker ./public/index.php"PHP has two common serving models in a container. The choice shows up in your Dockerfile, in how many containers you run, and in which bugs you can have.
nginx plus PHP-FPM
FastCGI Process Manager, PHP-FPM, is a pool of PHP processes waiting for work. A web server - usually nginx - serves static files itself and forwards anything matching the front controller to that pool over the FastCGI protocol. Each request boots the Symfony kernel, answers, and discards the whole process state. Two containers, two configuration files, and the request lifecycle nearly all PHP code assumes.
FrankenPHP
FrankenPHP is a single binary: a Caddy web server with PHP embedded in it. One container serves static files and PHP, with HTTP/2 and HTTP/3 available. In worker mode it boots your application once, keeps it in memory, and hands it request after request, so kernel boot stops being part of every response. Since Symfony 7.4 no extra package and no APP_RUNTIME are needed: FrankenPHP sets FRANKENPHP_WORKER in worker mode, SymfonyRuntime detects it and runs the kernel through Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner, so starting the server with FRANKENPHP_CONFIG="worker ./public/index.php" is the whole setup. The runtime/frankenphp-symfony package and its APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime value are only for Symfony versions before 7.4.
What worker mode changes
A long-lived process keeps whatever you put in it. A service that remembers something about the current request now remembers it for the next visitor. Symfony resets services tagged kernel.reset between main requests, and implementing Symfony\Contracts\Service\ResetInterface adds that tag through autoconfiguration. Symfony 8.1 extended resetting to non-shared services carrying the tag. Static properties are reset by nothing; avoiding them is yours to do.
Example
<?php
// src/Search/RecentSearches.php
declare(strict_types=1);
namespace App\Search;
use Symfony\Contracts\Service\ResetInterface;
/**
* Holds the search terms seen during the current request.
*
* Implementing ResetInterface is the shorthand: autoconfiguration adds the
* kernel.reset tag with method "reset" to any service implementing it, and
* that tag is what makes the service safe in worker mode. The longhand is the
* same tag written out in services.yaml - see the examples below. They are
* the same thing.
*/
final class RecentSearches implements ResetInterface
{
/** @var list<string> */
private array $terms = [];
public function add(string $term): void
{
$this->terms[] = $term;
}
/** @return list<string> */
public function all(): array
{
return $this->terms;
}
public function reset(): void
{
$this->terms = [];
}
}
// $searches = new RecentSearches();
// $searches->add('kubernetes');
// $searches->add('doctrine');
// $searches->all();
// Output: ['kubernetes', 'doctrine']
//
// $searches->reset();
// $searches->all();
// Output: []
//
// Without reset(), the second visitor served by the same worker process sees
// the first visitor's search terms.When to use it
- Choosing a serving model for a new Symfony image instead of inheriting someone else's Caddyfile.
- Removing kernel boot from every response on a high-traffic JSON endpoint.
- Tracking down a bug where one user sees another user's data after a move to worker mode.
- Moving an existing nginx and PHP-FPM setup into containers without changing how it serves.
More examples
Worker mode, configured two ways
Reach for the environment variable when the image is generic and the deployment decides; reach for the Caddyfile block when you need options the one-liner cannot express, such as num or max_consecutive_failures. Recognise both, because the widely copied Symfony Docker template ships a Caddyfile whose worker block is filled in from that variable.
# Dockerfile (runtime stage)
FROM dunglas/frankenphp:1-php8.5
WORKDIR /app
COPY --from=builder /app /app
# Short form: FRANKENPHP_CONFIG is injected under the frankenphp directive of
# the Caddyfile, so this one variable is the whole worker configuration.
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
ENV APP_ENV=prod
# No APP_RUNTIME is needed here. Since Symfony 7.4 symfony/runtime detects
# worker mode on its own: FrankenPHP sets FRANKENPHP_WORKER, and SymfonyRuntime
# then serves requests through FrankenPhpWorkerRunner.
# On Symfony versions before 7.4 you installed runtime/frankenphp-symfony and
# set, in .env (those backslashes are a PHP namespace, not line continuations):
# APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime
#
# Long form: the same settings written in the Caddyfile, where you can also
# set the thread count and the failure threshold.
#
# {
# frankenphp {
# worker {
# file ./public/index.php
# num 16
# max_consecutive_failures 10
# }
# }
# }
#
# "num" defaults to twice the number of available CPUs.nginx in front of PHP-FPM
The familiar pair: one container running nginx, one running php-fpm. Both need the application files, because nginx serves everything under public/ itself while FPM resolves SCRIPT_FILENAME against its own filesystem, so either share a volume or copy the same /app into both images. The internal directive makes /index.php/anything a 404 rather than a second URL for every page.
# docker/nginx/default.conf
server {
listen 80;
root /app/public;
location / {
# try to serve file directly, fallback to index.php
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
# In containers FPM listens on TCP, and "php" is the service name.
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
# return 404 for all other php files not matching the front controller
location ~ \.php$ {
return 404;
}
}The kernel.reset tag written out
Use the explicit tag for classes you do not own, or where the method is called something other than reset. Symfony calls the named method on every main request after the first; under PHP-FPM the tag is simply never acted on, so adding it costs nothing and makes the service correct under both serving models.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
# Longhand. Needed when the class cannot implement ResetInterface: a
# third-party service, or one whose clearing method has another name.
App\Search\RecentSearches:
tags:
- { name: 'kernel.reset', method: 'reset' }
Acme\Vendor\TokenCache:
tags:
- { name: 'kernel.reset', method: 'clearAll' }
Discussion