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

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

1 min read Mohamed Said Mohamed Said

FrankenPHP, OPcache JIT, and Preloading for Laravel

FrankenPHP is not just a novelty — it embeds PHP directly into a Caddy-based server and exposes a worker mode that keeps your Laravel application bootstrapped between requests, similar to Octane but without a separate process manager. Pair that with a well-tuned OPcache preload script and the right JIT mode and you have a meaningful throughput story without exotic infrastructure.

This article is about the specific, practical configuration decisions that matter — not a feature tour.


Worker Mode vs. Traditional Mode

In traditional mode FrankenPHP behaves like php-fpm: each request boots the framework from scratch. Worker mode changes this fundamentally:

// frankenphp-worker.php (placed at project root)
<?php

require __DIR__ . '/vendor/autoload.php';

$app = require_once __DIR__ . '/bootstrap/app.php';

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

// Signal FrankenPHP that the worker is ready
frankenphp_handle_request(function () use ($app, $kernel) {
    $request = Illuminate\Http\Request::capture();
    $response = $kernel->handle($request);
    $response->send();
    $kernel->terminate($request, $response);
});

The closure runs on every request; everything outside it runs once per worker lifetime. This is where state leakage lives — static properties, singleton bindings that cache user-specific data, and anything written to $_SERVER without cleanup will bleed across requests.

Rule of thumb: if you would not trust it in Octane, do not trust it here.


OPcache Preloading

Preloading compiles and caches PHP files into shared memory at server start. For Laravel this means the framework core, your service providers, and hot domain classes are already compiled before the first request arrives.

; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data

A minimal preload script for Laravel:

<?php
// preload.php

$files = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator(__DIR__ . '/vendor/laravel/framework/src')
);

foreach ($files as $file) {
    if ($file->isFile() && $file->getExtension() === 'php') {
        opcache_compile_file($file->getRealPath());
    }
}

// Also preload your own hot paths
opcache_compile_file(__DIR__ . '/app/Models/User.php');
opcache_compile_file(__DIR__ . '/app/Http/Kernel.php');

Important: opcache.validate_timestamps=0 means OPcache never checks disk for changes. You must restart the server after every deploy. Automate this or you will serve stale code.


Choosing the Right JIT Mode

PHP's JIT has two practical modes:

| Mode | opcache.jit value | Best for | |---|---|---| | Tracing | trasm / 1255 | CPU-bound loops, math-heavy code | | Function | function / 1205 | General web workloads |

For a typical Laravel request — mostly I/O waiting on MySQL and Redis — JIT provides modest gains. The real wins come from preloading and worker mode. That said, if you run CPU-intensive transformations (image processing, CSV parsing, report generation) inside the same process, tracing JIT is worth enabling:

opcache.jit=trasm
opcache.jit_buffer_size=128M

Do not set jit_buffer_size larger than you need. Oversized buffers waste shared memory and can cause allocation failures on low-RAM servers.


Caddyfile Configuration

{
    frankenphp
    order php_server before file_server
}

localhost {
    root * /var/www/html/public
    php_server {
        worker /var/www/html/frankenphp-worker.php
        num_workers 8
    }
}

num_workers should match your CPU core count as a starting point. Under FrankenPHP each worker is a long-lived PHP thread, not a process — memory is shared more efficiently than fpm pools.


Practical Takeaways

  • Worker mode eliminates framework bootstrap cost; guard against static state leakage the same way you would with Octane.
  • Preloading with validate_timestamps=0 is safe in production only when your deploy pipeline restarts the server.
  • JIT tracing mode helps CPU-bound work; for pure I/O-heavy Laravel apps the gain is real but modest — measure before claiming wins.
  • Keep opcache.memory_consumption generous; evictions under pressure negate the benefit entirely.
  • Profile with opcache_get_status() in a protected endpoint to verify preloaded file counts and hit rates after deploy.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Is FrankenPHP worker mode safe to use without Octane?
Yes. FrankenPHP worker mode is independent of Laravel Octane. You manage the worker bootstrap script yourself, which gives you full control but also full responsibility for cleaning up per-request state. Review your singletons and static properties the same way you would for Octane.
Q02 Does PHP JIT make a significant difference for typical Laravel API responses?
For I/O-bound endpoints the improvement is small — often single-digit percentages — because the bottleneck is database and cache latency, not CPU. JIT pays off noticeably for CPU-heavy work like data transformation, report generation, or anything running tight loops over large datasets.
Q03 What happens if I forget to restart FrankenPHP after deploying with validate_timestamps=0?
OPcache will continue serving the compiled bytecode from before your deploy. Your new code will not execute until the server restarts and OPcache is cleared. Always include a server restart step in your deploy pipeline when timestamps validation is disabled.

Continue reading

More Articles

View all