-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
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 |
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.
Between requests, the framework performs these cleanup steps:
-
PluginManager::resetAll()→ clears all cached plugins -
Re-registers default plugin folders — Template, Collection, Statement, Pipeline (fixes the plugin folder bug)
-
CompiledTemplate::clearCache()→ prevents memory growth from cached template segments -
Application and Distributor state reset
-
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
}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.
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.
# Generate Caddyfile (FrankenPHP worker mode, default)
php Razy.phar rewrite --caddy
# Generate .htaccess (Apache, default without --caddy)
php Razy.phar rewrite
| 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 |
# 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
The generated Caddyfile includes:
-
Global options —
frankenphpdirective andorder 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_*) withuri strip_prefix+file_serverfor each module'swebassets/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 plainphp_server(standard mode)
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
}
}
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 (RewriteRuleCompiler → htaccess.tpl → Application::updateRewriteRules()).
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
}
}
| 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 |
Before deploying with worker mode, verify each item:
-
✅ Review static variables — Audit all
staticproperties 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
wrkorab) and verify correct responses under load. -
✅ Monitor memory — Watch memory usage over sustained load to detect leaks. Use
memory_get_usage()logging if needed.