FrankenPHP, OPcache JIT, and Preloading: Squeezing Real Throughput from Laravel
#laravel #frankenphp #performance #opcache #php

FrankenPHP, OPcache JIT, and Preloading: Squeezing Real Throughput from Laravel

1 min read Mohamed Said Mohamed Said

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_file on 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=0 in production — it is the cheapest win.
  • Preload only pure class files; side-effect files will break worker startup.
  • Use jit=tracing with a 128 MB buffer for Laravel's dispatch-heavy workload.
  • Start worker count at CPU cores × 2 and 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does FrankenPHP worker mode replace Laravel Octane?
They solve the same problem — keeping Laravel bootstrapped between requests — but differently. Octane wraps Swoole or RoadRunner; FrankenPHP embeds PHP in a Go-based Caddy server. You do not need Octane when using FrankenPHP worker mode, but the state-leakage concerns are identical.
Q02 Is OPcache JIT safe to enable on any PHP 8.x version?
JIT has been stable since PHP 8.0. The main risk is incorrect JIT output on edge-case code, which is extremely rare in practice. Enable it in staging first, run your full test suite, then promote to production. There is no reason to leave it off for a standard Laravel app.
Q03 How do I know if my preload script is actually working?
Run `php --ri opcache` after starting the server and look for `Preload Statistics`. It lists every class that was successfully preloaded. Alternatively, call `opcache_get_status(true)` from a protected diagnostic endpoint and inspect the `preload_statistics` key.

Continue reading

More Articles

View all