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