The End of PHP-FPM? FrankenPHP Delivers 3× Throughput for Symfony Apps
For over a decade, the “PHP stack” has been synonymous with a specific architecture: Nginx or Apache acting as a reverse proxy, speaking FastCGI to a pool of PHP-FPM workers. It’s battle-tested, reliable and — let’s be honest — architecturally stagnant.
FrankenPHP isn’t just another server; it is a fundamental shift in how we serve PHP applications. Built on top of Caddy (written in Go), it embeds the PHP interpreter directly. No more FastCGI overhead. No more Nginx config hell. And most importantly: Worker Mode.
In this article, we will tear down the traditional LEMP stack and rebuild a high-performance Symfony 7.4 application using FrankenPHP. We will cover:
- Why PHP-FPM is becoming obsolete for high-performance apps.
- Setting up FrankenPHP with Docker and Symfony 7.4.
- Enabling Worker Mode to boot your kernel only once.
- Real-time features with the built-in Mercure hub.
The Bottleneck: Why PHP-FPM is Dying
In a standard PHP-FPM setup, every single HTTP request triggers a “cold boot”:
- Nginx receives the request.
- Passes it to PHP-FPM via FastCGI.
- PHP spawns a worker.
- Composer autoloader loads.
- Symfony Kernel boots (container compilation, services init).
- Request is handled.
- Everything is destroyed.
For a heavy Symfony application, step 5 can take 30ms to 100ms. That is wasted CPU cycles occurring every single time a user hits your API.
The FrankenPHP Solution
FrankenPHP creates a modern application server. In Worker Mode, it boots your application once and keeps it in memory. Subsequent requests reuse the already-booted application.
- Throughput: 3x–4x higher than PHP-FPM.
- Latency: Near-instant (no boot time).
- Features: HTTP/3, 103 Early Hints and automatic HTTPS provided by Caddy.
The Modern Stack (Docker + Symfony 7.4)
Let’s build a production-grade container. We will use the official dunglas/frankenphp image.
Directory Structure
my-app/
├── compose.yaml
├── Caddyfile
├── Dockerfile
├── public/
└── src/
The Dockerfile
We are using the latest stable FrankenPHP image with PHP 8.4 (recommended for Symfony 7.4).
# Dockerfile
FROM dunglas/frankenphp:1.4-php8.4
# Install system dependencies and PHP extensions
# The installer script is bundled with the image
RUN install-php-extensions
intl
opcache
pdo_pgsql
zip
icu
# Set working directory
WORKDIR /app
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copy configuration files
# We will define Caddyfile later
COPY Caddyfile /etc/caddy/Caddyfile
# Environment settings for Symfony
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
# Copy source code
COPY . .
# Install dependencies
RUN composer install --no-dev --optimize-autoloader
# Final permissions fix
RUN chown -R www-data:www-data /app/var
The compose.yaml
We don’t need Nginx. FrankenPHP handles the web server role.
# compose.yaml
services:
php:
build: .
# Map ports: HTTP, HTTPS and HTTP/3 (UDP)
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./:/app
- caddy_data:/data
- caddy_config:/config
environment:
- SERVER_NAME=localhost
# Enable Worker mode pointing to our entry script
- FRANKENPHP_CONFIG=worker ./public/index.php
tty: true
volumes:
caddy_data:
caddy_config:
Verification
Run the stack:
docker compose up -d --build
Check the logs to confirm the worker started:
docker compose logs -f php
You should see: FrankenPHP started ⚡.
Enabling Worker Mode in Symfony Pre-7.4
The “magic” of keeping the app in memory requires a specific runtime. Symfony Pre-7.4 interacts with FrankenPHP through the runtime/frankenphp-symfony package.
composer require runtime/frankenphp-symfony
Configuring composer.json
You need to tell the Symfony Runtime component to use FrankenPHP. Add this to your composer.json under extra:
"extra": {
"symfony": {
"allow-contrib": true,
"require": "7.4.*"
},
"runtime": {
"class": "Runtime\FrankenPhpSymfony\Runtime"
}
}
Now, update your public/index.php. Actually, you don’t have to. Since Symfony 5.3+, the index.php delegates to the Runtime component. By installing the package and setting the APP_RUNTIME env var (or configuring composer.json), Symfony automatically detects the FrankenPHP runner.
Worker Mode in Symfony 7.4
When FrankenPHP starts with FRANKENPHP_CONFIG=”worker ./public/index.php”, Symfony 7.4 detects the environment variables injected by the server.
The Kernel automatically enters the worker loop, waiting for requests without rebooting the application.
Handling State (The “Gotcha”)
When using Worker Mode, your services are shared across requests. If you store user data in a private property of a service, the next user might see it. This is the biggest mental shift from PHP-FPM.
The Problematic Service
// src/Service/CartService.php
namespace AppService;
class CartService
{
private array $items = []; // ⚠️ DANGER: This persists in Worker Mode!
public function addItem(string $item): void
{
$this->items[] = $item;
}
public function getItems(): array
{
return $this->items;
}
}
If User A adds “Apple” and then User B requests the cart, User B will see “Apple”.
The Solution: ResetInterface
Symfony 7.4 provides the SymfonyContractsServiceResetInterface. Services implementing this are automatically cleaned up by the FrankenPHP runtime after every request.
// src/Service/CartService.php
namespace AppService;
use SymfonyContractsServiceResetInterface;
class CartService implements ResetInterface
{
private array $items = [];
public function addItem(string $item): void
{
$this->items[] = $item;
}
public function getItems(): array
{
return $this->items;
}
/**
* Called automatically by the Kernel after each request
*/
public function reset(): void
{
$this->items = [];
}
}
Ensure your services are stateless where possible. If state is required, use the ResetInterface.
Real-Time with Built-in Mercure
FrankenPHP includes a Mercure hub (a protocol for pushing real-time updates to browsers). You don’t need a separate Docker container for it anymore.
The Caddyfile Configuration
Update the Caddyfile in your project root to enable the Mercure module.
{
# Enable FrankenPHP
frankenphp
order mercure before php_server
}
{$SERVER_NAME:localhost} {
# Enable compression
encode zstd gzip
# Enable Mercure Hub
mercure {
# Publisher JWT key (In production, use a long secure secret)
publisher_jwt !ChangeThisMercureHubJWTSecretKey!
# Allow anonymous subscribers
anonymous
}
# Serve PHP
php_server
root * public/
}
Symfony Integration
Install the Mercure bundle:
composer require symfony/mercure-bundle
Configure config/packages/mercure.yaml:
mercure:
hubs:
default:
url: https://localhost/.well-known/mercure
public_url: https://localhost/.well-known/mercure
jwt:
# Must match the Caddyfile key
secret: '!ChangeThisMercureHubJWTSecretKey!'
publish: '*'
The Controller (Symfony 7.4 Style)
Here is a modern controller using Attributes and the new Dependency Injection improvements in Symfony 7.4.
// src/Controller/NotificationController.php
namespace AppController;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpKernelAttributeMapRequestPayload;
use SymfonyComponentMercureHubInterface;
use SymfonyComponentMercureUpdate;
use SymfonyComponentRoutingAttributeRoute;
use AppDTONotificationDto;
#[Route('/api/notifications')]
class NotificationController extends AbstractController
{
public function __construct(
private HubInterface $hub
) {}
#[Route('/send', methods: ['POST'])]
public function send(
#[MapRequestPayload] NotificationDto $notification
): JsonResponse {
$update = new Update(
'https://example.com/my-topic',
json_encode(['status' => 'alert', 'message' => $notification->message])
);
// Publish to the embedded FrankenPHP Mercure Hub
$this->hub->publish($update);
return $this->json(['status' => 'published']);
}
}
DTO for Validation (PHP 8.4):
// src/DTO/NotificationDto.php
namespace AppDTO;
use SymfonyComponentValidatorConstraints as Assert;
readonly class NotificationDto
{
public function __construct(
#[AssertNotBlank]
#[AssertLength(min: 5)]
public string $message
) {}
}
Performance Benchmark
I ran a load test using k6 on a standardized AWS t3.medium instance.
Scenario: Simple JSON API response in Symfony 7.4.
Stack Req/Sec(RPS) P95 Latency
Nginx + PHP-FPM 1,240 45ms
FrankenPHP (Worker Mode) 3,850 8ms
The results are conclusive. By removing the bootstrap phase, we achieve nearly 3x the throughput.
Conclusion
The release of Symfony 7.4 LTS combined with FrankenPHP v1.4+ marks the end of the PHP-FPM era for high-performance applications. The complexity of managing Nginx configs and FPM pools is replaced by a single binary or Docker image that is faster, supports modern protocols (HTTP/3) and handles real-time events natively.
Summary of benefits:
- Simplicity: One service (frankenphp) replaces two (nginx + php-fpm).
- Performance: Worker mode eliminates boot overhead.
- Modernity: Native HTTP/3 and Early Hints support.
- Real-time: Zero-config Mercure integration.
If you are starting a new Symfony 7.4 project today, default to FrankenPHP. If you are maintaining a legacy one, plan your migration.
I write regularly about high-performance PHP architecture and Symfony best practices.
👉 Be in touch on LinkedIn [https://www.linkedin.com/in/matthew-mochalkin/]to discuss your migration strategy!