Skip to content

Caddy Worker Mode

Ray Fung edited this page Feb 26, 2026 · 4 revisions

Caddy Worker Mode

Razy supports FrankenPHP worker mode for dramatic performance improvements. In worker mode, the framework boots once and handles requests in a persistent loop — achieving 3→ 0x throughput over traditional PHP-FPM.

Overview

FrankenPHP is a modern PHP application server built on Caddy. Its worker mode keeps the PHP process alive between requests, eliminating the boot-teardown cycle that dominates traditional PHP performance. Razy integrates natively with this model — no code changes required.

How It Works

Traditional PHP-FPM processes each request as an independent lifecycle:


// Traditional PHP-FPM (per request)

Boot →Load PHAR →Bootstrap →Autoloaders →Handle Request →Teardown



// FrankenPHP Worker Mode

Boot →Load PHAR →Bootstrap →Autoloaders  // One-time

  →→?Handle Request →Reset →Handle Request →Reset →...  // Loop

The boot phase happens once. Each subsequent request enters the handler loop, skipping all initialization overhead.

Auto-Detection

Razy automatically detects worker mode — no manual configuration or code changes needed:


// Detection methods (checked automatically)

function_exists('frankenphp_handle_request')  // Native detection

getenv('CADDY_WORKER_MODE') === 'true'       // Environment variable

If either condition is true, the framework switches to worker mode automatically. Your controllers, modules, and routing logic work identically in both modes.

Architecture

The worker mode architecture separates one-time boot from per-request handling:

| Phase | What Happens | Frequency |

| --- | --- | --- |

| Boot | Load PHAR, bootstrap framework, register autoloaders | Once |

| Request Loop | Create Application — match domain — load distributor — handle route | Per request |

| Dispose | Clear request state, reset event bindings, garbage collect | Per request |

State Management

Understanding what persists and what resets between requests is critical for correct behavior in worker mode:

| Category | Behavior | Examples |

| --- | --- | --- |

| Persists | Shared across all requests | Controller class definitions, framework code, autoloader registry, module metadata |

| Resets | Cleared per request | Routing tables, event bindings, closures, Agent state, compiled template cache, plugin folder registrations |

The framework automatically handles resetting request-scoped state. Class definitions and autoloaders persist because they are immutable and expensive to rebuild.

Worker Loop Reset Sequence

Between requests, the framework performs these cleanup steps:

  1. PluginManager::resetAll() → clears all cached plugins

  2. Re-registers default plugin folders — Template, Collection, Statement, Pipeline (fixes the plugin folder bug)

  3. CompiledTemplate::clearCache() → prevents memory growth from cached template segments

  4. Application and Distributor state reset

Best Practices

  • Use request-scoped data — Store request-specific data in controller instance variables, not static properties.

  • Reset in __onInit() — Always reset instance variables in your controller's __onInit() method. This runs at the start of each request.

  • Avoid persistent state in controllers — Don't rely on controller state surviving between requests. Treat each request as isolated.

  • No static variables for request data — Static variables persist across requests in worker mode. Use them only for truly global, immutable data.

  • Close resources explicitly — Database connections, file handles, and streams should be closed at the end of each request cycle.

// ✅ Correct: Reset state in __onInit()

class MyController extends Controller

{

    private $requestData = [];



    public function __onInit(): void

    {

        $this->requestData = [];  // Reset per request

    }

}



// ❌ Wrong: Static variable leaks across requests

class BadController extends Controller

{

    private static $cache = [];  // Persists! Leaks data between requests

}

Performance

Benchmark results comparing traditional PHP-FPM with FrankenPHP worker mode:

| Metric | PHP-FPM | FrankenPHP Worker | Improvement |

| --- | --- | --- | --- |

| Requests/sec | ~450 | ~3,200 | 7.1x faster |

| Response time | ~22ms | ~3ms | 7.3x faster |

| Memory per request | ~2.1 MB | ~1.45 MB | 31% less |

These gains come from eliminating per-request PHAR loading, autoloader registration, and framework bootstrapping.

Caddyfile Generation (CLI)

Razy can automatically generate a production-ready Caddyfile from your sites.inc.php multisite configuration. This is the Caddy equivalent of php Razy.phar rewrite which generates .htaccess for Apache.

Basic Usage

# Generate Caddyfile (FrankenPHP worker mode, default)

php Razy.phar rewrite --caddy



# Generate .htaccess (Apache, default without --caddy)

php Razy.phar rewrite

Options

| Option | Description | Default |

| --- | --- | --- |

| --caddy | Generate Caddyfile instead of .htaccess | Off (Apache) |

| --no-worker | Use standard php_server instead of FrankenPHP worker mode | Worker mode on |

| --document-root=PATH | Set the server document root path | /app/public |

Examples

# Worker mode with default document root

php Razy.phar rewrite --caddy



# Standard mode (no worker) for development

php Razy.phar rewrite --caddy --no-worker



# Custom document root

php Razy.phar rewrite --caddy --document-root=/var/www/html

What Gets Generated

The generated Caddyfile includes:

  • Global optionsfrankenphp directive and order php_server before file_server

  • Per-domain site blocks — One block per domain in sites.inc.php, including domain aliases as additional addresses

  • Webasset handlers — Named matchers (@webasset_*) with uri strip_prefix + file_server for each module's webassets/ directory

  • Data mapping handlers — Named matchers (@data_*) for distributor data directories

  • Shared module paths — Handler for /*/shared/* paths

  • PHP execution — Either worker /app/public/index.php (worker mode) or plain php_server (standard mode)

Generated Output Example

For a multisite configuration with example.com and alias www.example.com:

{

    frankenphp

    order php_server before file_server

}



example.com, www.example.com {

    root * /app/public



    # Webassets: mymodule

    @webasset_mymodule_ path /webassets/mymodule/*

    handle @webasset_mymodule_ {

        uri strip_prefix /webassets/mymodule

        root * sites/example.com/vendor/mymodule/1.0.0

        file_server

    }



    # Data mapping: main

    @data_main__0 path /data/*

    handle @data_main__0 {

        uri strip_prefix /data

        root * /app/public/data/example.com-main

        file_server

    }



    # Shared modules

    @shared path /*/shared/*

    handle @shared {

        root * /app/public

        uri replace /shared/ /shared/ 1

        file_server

    }



    php_server {

        worker /app/public/index.php

    }

}

Architecture

The Caddyfile generation pipeline:

| Component | File | Role |

| --- | --- | --- |

| CaddyfileCompiler | src/library/Razy/Routing/CaddyfileCompiler.php | Compiles multisite config into Caddyfile output |

| caddyfile.tpl | src/asset/setup/caddyfile.tpl | Razy template for Caddyfile structure |

| Application::updateCaddyfile() | src/library/Razy/Application.php | Entry point, writes Caddyfile to project root |

| rewrite CLI command | src/system/terminal/rewrite.inc.php | CLI interface with --caddy flag |

This mirrors the existing Apache pipeline (RewriteRuleCompilerhtaccess.tplApplication::updateRewriteRules()).

Configuration

Docker Compose setup for FrankenPHP worker mode:

# docker-compose.yml

services:

  php:

    image: dunglas/frankenphp

    volumes:

      - .:/app/public

    environment:

      - CADDY_WORKER_MODE=true

Caddyfile configuration (manual):

# Caddyfile (manual example — or generate with: php Razy.phar rewrite --caddy)

{

    frankenphp

    order php_server before file_server

}



localhost {

    root * /app/public

    php_server {

        worker /app/public/index.php

    }

}

Troubleshooting

| Issue | Cause | Solution |

| --- | --- | --- |

| State leaking between requests | Static variables or global state persisting | Move to instance variables; reset in __onInit() |

| Memory growing over time | Objects not being garbage collected | Unset large variables; check for circular references |

| Session data from wrong user | Session not properly reset | Ensure session_write_close() is called; let the framework handle session lifecycle |

| Database connection errors | Connections exceeding max lifetime | Use connection pooling or reconnect logic; don't store connections in static variables |

| Stale configuration | Config cached from boot phase | Reload config per-request if it changes; use config.inc.php for static-only settings |

Migration Checklist

Before deploying with worker mode, verify each item:

  • Review static variables — Audit all static properties in controllers and modules. Ensure they hold immutable data only.

  • Reset instance variables — Add or verify __onInit() resets in all controllers.

  • Test sessions — Confirm session data isolates correctly between concurrent requests.

  • Check database lifecycle — Ensure connections are created per-request or use connection pooling.

  • Load test — Run concurrent load tests (e.g., with wrk or ab) and verify correct responses under load.

  • Monitor memory — Watch memory usage over sustained load to detect leaks. Use memory_get_usage() logging if needed.

← PreviousRepository & Publishing

Next → CLI Commands

Clone this wiki locally