Production-Ready Laravel with FrankenPHP and Traefik
The traditional PHP deployment model is wasteful. Every request boots the entire framework, loads configuration, resolves dependencies, then throws it all away. Thousands of times per minute, the same initialization ritual.
FrankenPHP changes this. Combined with Traefik as a reverse proxy, we get a production architecture that’s both modern and remarkably efficient.
The Stack
┌─────────────────────────────────────────┐
│ Traefik │
│ (Reverse Proxy + SSL) │
└─────────────────┬───────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ FrankenPHP │ │ FrankenPHP │
│ (Worker) │ │ (Worker) │
│ Laravel │ │ Laravel │
└───────────────┘ └───────────────┘
│ │
└─────────┬─────────┘
▼
┌───────────────┐
│ MySQL │
│ Redis │
└───────────────┘
Traefik handles routing, SSL termination, and load balancing. FrankenPHP runs Laravel in worker mode — the application boots once and handles thousands of requests without restarting.
Benchmark Reality: The Numbers Don’t Lie
Before diving into implementation, let’s look at real benchmark data. These numbers justify the architectural complexity.
Laravel Octane Server Comparison
Tests conducted on Apple MacBook M1 Pro using Pest’s Stressless plugin (source):
Single Concurrent Request (5 seconds)
| Server | Median Response Time |
|---|---|
| FrankenPHP | 0.88 ms |
| RoadRunner | 2.61 ms |
| Swoole | 4.94 ms |
8 Concurrent Requests (5 seconds)
| Server | Median Response Time |
|---|---|
| FrankenPHP | 1.59 ms |
| RoadRunner | 4.00 ms |
| Swoole | 5.39 ms |
FrankenPHP is 3x faster than RoadRunner and 5x faster than Swoole in these tests.
PHP Application Server Throughput
General PHP application server benchmarks (source):
| Application Server | Requests/sec | Memory per Connection |
|---|---|---|
| OpenSwoole | 1,500 | ~1 MB |
| RoadRunner | 1,300 | ~1 MB |
| FrankenPHP | 1,200 | ~1.5 MB |
| Nginx + PHP-FPM | 1,000 | ~2 MB |
| Apache + mod_php | 800 | ~15 MB |
Framework Baseline Performance
How frameworks compare before optimization (source):
| Framework | Response Time | Memory | Throughput |
|---|---|---|---|
| CodeIgniter | 90 ms | 20 MB | 250 req/s |
| Yii | 110 ms | 25 MB | 220 req/s |
| Laravel | 120 ms | 30 MB | 200 req/s |
| Symfony | 150 ms | 40 MB | 180 req/s |
Laravel’s rich feature set comes with overhead. FrankenPHP eliminates the repeated bootstrap cost, making Laravel competitive with lighter frameworks.
The Real-World Impact
Combining the data:
| Stack | Estimated Throughput | p99 Latency |
|---|---|---|
| Laravel + Apache + mod_php | ~160 req/s | ~200 ms |
| Laravel + Nginx + PHP-FPM | ~200 req/s | ~120 ms |
| Laravel + RoadRunner | ~650 req/s | ~45 ms |
| Laravel + Swoole | ~500 req/s | ~55 ms |
| Laravel + FrankenPHP | ~800 req/s | ~28 ms |
| Laravel + FrankenPHP (4 workers) | ~2,800 req/s | ~35 ms |
The difference isn’t marginal. It’s transformational.
Why FrankenPHP
FrankenPHP is built on Caddy, inheriting its automatic HTTPS, HTTP/2, and HTTP/3 support. The PHP Foundation officially supports FrankenPHP as of 2025, signaling its importance to the ecosystem (source).
Traditional PHP-FPM:
Request → Boot Laravel → Handle → Destroy → Response
Request → Boot Laravel → Handle → Destroy → Response
Request → Boot Laravel → Handle → Destroy → Response
FrankenPHP Worker Mode:
Boot Laravel (once)
Request → Handle → Response
Request → Handle → Response
Request → Handle → Response
The framework stays warm. Service containers remain resolved. Config stays loaded.
The Docker Compose Setup
services:
traefik:
image: traefik:v3.0
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- web
app:
build:
context: .
dockerfile: Dockerfile
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=8000"
environment:
- APP_ENV=production
- FRANKENPHP_CONFIG=worker ./public/index.php
volumes:
- .:/app
networks:
- web
- internal
depends_on:
- mysql
- redis
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE}
volumes:
- mysql_data:/var/lib/mysql
networks:
- internal
redis:
image: redis:alpine
networks:
- internal
volumes:
letsencrypt:
mysql_data:
networks:
web:
external: true
internal:
The Dockerfile
FROM dunglas/frankenphp:latest-php8.3-alpine
# Install PHP extensions
RUN install-php-extensions \
pdo_mysql \
redis \
opcache \
pcntl \
intl
# Copy application
COPY . /app
WORKDIR /app
# Install dependencies
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN composer install --no-dev --optimize-autoloader
# Optimize Laravel
RUN php artisan config:cache && \
php artisan route:cache && \
php artisan view:cache
# FrankenPHP worker mode
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
EXPOSE 8000
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
The Caddyfile
{
frankenphp
order php_server before file_server
}
:8000 {
root * /app/public
php_server {
worker {
file ./public/index.php
num {$PHP_WORKERS:4}
}
}
file_server
encode zstd gzip
header {
-Server
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
Laravel Octane Integration
Laravel Octane provides the worker abstraction. FrankenPHP is a first-class driver:
composer require laravel/octane
php artisan octane:install --server=frankenphp
// config/octane.php
return [
'server' => 'frankenphp',
'workers' => env('OCTANE_WORKERS', 4),
'task_workers' => env('OCTANE_TASK_WORKERS', 2),
'max_requests' => 1000,
];
Memory Considerations
Worker mode means state persists. This is powerful but dangerous.
// Bad: Static state accumulates
class RequestCounter
{
public static int $count = 0;
public function handle()
{
self::$count++; // Grows forever across requests
}
}
// Good: Use Octane's flush mechanism
use Laravel\Octane\Facades\Octane;
Octane::tick('metrics', fn () => $this->flushMetrics())
->seconds(10);
Register cleanup in config/octane.php:
'flush' => [
\App\Services\RequestCounter::class,
],
Traefik Middleware
Add rate limiting, authentication, or compression at the proxy level:
labels:
# Rate limiting
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.ratelimit.ratelimit.burst=50"
# Compression
- "traefik.http.middlewares.compress.compress=true"
# Headers
- "traefik.http.middlewares.security.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security.headers.stsIncludeSubdomains=true"
# Apply middleware chain
- "traefik.http.routers.app.middlewares=ratelimit,compress,security"
Health Checks
Traefik can route traffic away from unhealthy containers:
labels:
- "traefik.http.services.app.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.app.loadbalancer.healthcheck.interval=10s"
// routes/web.php
Route::get('/health', function () {
try {
DB::connection()->getPdo();
Redis::ping();
return response('OK', 200);
} catch (\Exception $e) {
return response('UNHEALTHY', 503);
}
});
Scaling
Horizontal scaling becomes trivial:
docker compose up -d --scale app=4
Traefik automatically discovers new containers and distributes load. No nginx reload. No configuration changes. Just more workers.
Choosing Your Stack
Based on the benchmarks, here’s a decision framework:
| Use Case | Recommended Stack |
|---|---|
| Maximum single-request speed | FrankenPHP |
| Highest concurrent throughput | OpenSwoole |
| Best memory efficiency | RoadRunner |
| Simplest deployment | FrankenPHP (Caddy built-in) |
| Legacy compatibility | Nginx + PHP-FPM |
| Websockets/Real-time | Swoole or FrankenPHP (Mercure) |
The Tradeoffs
Nothing is free.
Memory: Workers hold the application in memory. Each worker consumes 50-100MB depending on your app. Plan accordingly.
State bugs: Singletons persist. Static variables accumulate. Code that worked in PHP-FPM may leak in worker mode.
Debugging complexity: Issues that only appear after thousands of requests are harder to reproduce locally.
Ecosystem maturity: Swoole has years of production battle-testing. FrankenPHP is newer but rapidly maturing with PHP Foundation backing.
When to Use This
This architecture makes sense when:
- You need sub-50ms response times
- You’re scaling horizontally with containers
- You want automatic SSL without Certbot cron jobs
- Your team understands worker mode implications
It’s overkill when:
- Your Laravel app handles 10 requests per minute
- You’re running on shared hosting
- You need FTP deployments
The Command
# Create the external network
docker network create web
# Start everything
docker compose up -d
# Watch Traefik discover your services
docker compose logs -f traefik
Your Laravel application is now running on a modern stack. HTTPS works automatically. Scaling is a flag. Response times are measured in milliseconds.
The PHP ecosystem has evolved. Our deployment architectures should too.
Benchmark sources: Laravel Blog, DeployHQ, The PHP Foundation