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

FrankenPHP embeds a PHP runtime directly into a Caddy-based binary. Unlike Swoole or RoadRunner, it requires no PHP extension and no separate process manager — the worker lifecycle is handled by the server itself. Combined with OPcache preloading and JIT compilation, it can serve Laravel applications with latency profiles that rival Node.js, without rewriting a line of application code.

This article focuses on three levers: worker mode, preload scripts, and JIT configuration. Each has sharp edges worth knowing before you push to production.


Worker Mode: Persistent Application Bootstrap

FrankenPHP's worker mode boots your Laravel application once and reuses it across requests — the same model Octane uses, but without the Octane dependency.

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

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

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

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

while (\frankenphp_handle_request(function () use ($app, $kernel) {
    $request = Illuminate\Http\Request::capture();
    $response = $kernel->handle($request);
    $response->send();
    $kernel->terminate($request, $response);
})) {
    // loop continues; add per-request cleanup here if needed
    gc_collect_cycles();
}

The Caddyfile wires it up:

{
    frankenphp
}

localhost {
    root * /var/www/public
    php_server {
        worker /var/www/public/frankenphp-worker.php 4
    }
}

The integer 4 is the number of worker processes. Start at (CPU cores × 2) and profile from there.

State leakage is real. Anything stored in a static property or a singleton that is not reset between requests will bleed across users. Audit your service providers and reset state in the worker loop or via $app->forgetInstance().


OPcache Preloading

Preloading compiles and caches PHP files into shared memory at server start, eliminating per-request compilation overhead for your hottest code paths.

; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0   ; production only
opcache.preload=/var/www/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->getPathname());
    }
}

Preloading the framework core (≈1,200 files) typically saves 2–4 ms per cold-path request. Preloading your own app/ directory adds marginal benefit unless your domain layer is large.

Gotcha: opcache.validate_timestamps=0 means file changes are invisible until the server restarts. Never enable this in development.


JIT: Tracing vs Function Mode

PHP's JIT compiler operates in two main modes:

| Mode | opcache.jit value | Best for | |---|---|---| | Tracing | 1255 | Long-running loops, math-heavy code | | Function | 1205 | General web workloads |

For typical Laravel request/response cycles, function mode (1205) is the safer default. Tracing JIT can regress performance on short-lived, branch-heavy code because the tracing overhead outweighs the gains.

opcache.jit=1205
opcache.jit_buffer_size=128M

Verify JIT is active:

php -r "var_dump(opcache_get_status()['jit']['enabled']);"
# bool(true)

Profile with Blackfire before and after enabling JIT. On pure I/O-bound Laravel apps the improvement is modest (5–15%). On CPU-bound workloads — PDF generation, image processing, complex collection pipelines — gains can exceed 30%.


Production Checklist

  • Disable validate_timestamps in production; add a deploy step that calls opcache_reset() or restarts FrankenPHP workers.
  • Pin worker count to a value you've load-tested; over-provisioning wastes memory without throughput gains.
  • Reset bound singletons between requests when using worker mode — especially auth guards and request-scoped services.
  • Monitor JIT buffer exhaustion via opcache_get_status()['jit']['buffer_free']; increase jit_buffer_size if it approaches zero.
  • Test preload scripts in CI — a syntax error in a preloaded file crashes the entire server process.

Key Takeaways

  • FrankenPHP worker mode eliminates bootstrap overhead without requiring Octane or Swoole.
  • Preloading the framework core is low-risk and measurably reduces per-request compilation time.
  • JIT function mode (1205) is the right default for Laravel; tracing mode requires profiling to justify.
  • State leakage between requests is the primary operational risk of any persistent-worker model.
  • Always benchmark with realistic traffic shapes before attributing gains to any single lever.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need Laravel Octane to use FrankenPHP worker mode?
No. FrankenPHP provides its own worker loop via `frankenphp_handle_request()`. Octane adds conveniences like automatic state reset and Swoole/RoadRunner abstractions, but it is not required when running under FrankenPHP directly.
Q02 Is JIT worth enabling for a typical Laravel API?
For pure I/O-bound APIs the gain is modest. JIT pays off most on CPU-intensive work — complex collection transformations, report generation, or cryptographic operations. Always profile with Blackfire or a load tester before committing to the configuration change.
Q03 How do I safely reset singletons between requests in worker mode?
Call `$app->forgetInstance(AbstractClass::class)` inside the worker loop after each request, or rebind the service in a middleware that runs on every request. For auth guards, calling `Auth::forgetGuards()` is the idiomatic reset.

Continue reading

More Articles

View all