Why FrankenPHP Changes the Equation
Traditional PHP-FPM restarts the PHP runtime on every request. FrankenPHP's worker mode boots Laravel once, keeps it in memory, and hands each request to a long-lived worker process — the same idea as Laravel Octane, but baked into the server binary itself. Pair that with OPcache JIT and preloading and you eliminate three of the biggest per-request costs: bootstrap, compilation, and opcode caching misses.
This article focuses on the configuration layer. Getting the numbers right matters more than the concept.
Layer 1 — OPcache Baseline
Before touching JIT, make sure OPcache is tuned for a large Laravel application. The defaults are conservative.
; php.ini (or conf.d/opcache.ini)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; disable in production
opcache.save_comments=1 ; required by Laravel annotations / attributes
opcache.revalidate_freq=0
validate_timestamps=0 is the single highest-impact change for production. Without it PHP stat-checks every file on every request even when OPcache is warm.
Layer 2 — Preloading
Preloading compiles a set of files at worker startup and pins them in shared memory. Every worker process shares those opcodes without copying.
Create preload.php at the project root:
<?php
// preload.php
require __DIR__ . '/vendor/autoload.php';
$loader = new \Symfony\Component\ClassLoader\ClassMapGenerator();
// Preload the framework core and your most-hit domain classes
$paths = [
__DIR__ . '/vendor/laravel/framework/src',
__DIR__ . '/app/Models',
__DIR__ . '/app/Http/Controllers',
];
foreach ($paths as $path) {
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) {
if ($file->getExtension() !== 'php') continue;
opcache_compile_file($file->getRealPath());
}
}
Then wire it up:
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data
Gotcha: preloaded classes cannot be redeclared. If you use
opcache_compile_fileon a file that has side effects (e.g., a bootstrap file that registers globals), you will get fatal errors at startup. Stick to pure class files.
Layer 3 — JIT
JIT compiles hot opcodes to native machine code at runtime. For Laravel's workload — heavily I/O-bound with moderate CPU in serialisation and validation — the gains are real but not dramatic. Expect 10–25 % throughput improvement on CPU-heavy paths (e.g., JSON transformation, rule evaluation loops).
opcache.jit=tracing ; "tracing" outperforms "function" for Laravel
opcache.jit_buffer_size=128M
The tracing mode profiles call paths across function boundaries, which suits Laravel's layered dispatch chain better than function mode.
Layer 4 — FrankenPHP Worker Mode
Create a Caddyfile:
{
frankenphp
order php_server before file_server
}
localhost {
root * /var/www/html/public
php_server {
worker /var/www/html/public/index.php 8
}
}
The 8 is the worker count. A safe starting point is (CPU cores × 2). Each worker holds a bootstrapped Laravel application in memory.
Your public/index.php needs a worker loop:
<?php
use Illuminate\Http\Request;
require __DIR__ . '/../vendor/autoload.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
// FrankenPHP worker loop
$handler = function () use ($app): void {
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$request = Request::capture();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
};
if (\function_exists('frankenphp_handle_request')) {
// Worker mode: loop until the server signals shutdown
while (frankenphp_handle_request($handler)) {
gc_collect_cycles(); // keep memory stable between requests
}
} else {
// Fallback: standard single-request mode
$handler();
}
Avoiding State Leakage
Worker mode keeps the same process alive across requests. Anything stored in static properties or global state bleeds between requests. Audit your code for:
- Static caches on service classes (
static $instance) app()->instance()bindings that are not re-resolved per request- Facades that cache their underlying instance across the loop
Laravel's service container is re-used per worker, not per request, in worker mode. Bind request-scoped services with $app->scoped() rather than $app->singleton() where state must reset.
Takeaways
- Set
opcache.validate_timestamps=0in production — it is the cheapest win. - Preload only pure class files; side-effect files will break worker startup.
- Use
jit=tracingwith a 128 MB buffer for Laravel's dispatch-heavy workload. - Start worker count at
CPU cores × 2and tune from there with load testing. - Audit for static state leakage before enabling worker mode — it is the most common production bug.
$app->scoped()is your friend for request-scoped services in long-lived workers.