FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput
#laravel #frankenphp #opcache #performance #php

FrankenPHP, OPcache JIT, and Preloading: Maximising Laravel Throughput

1 min read Mohamed Said Mohamed Said

Why FrankenPHP Changes the Equation

Traditional PHP-FPM spawns a fresh process per request, discarding compiled bytecode between requests unless OPcache is warm. FrankenPHP embeds PHP directly into a Caddy-based server and, crucially, supports worker mode — a long-lived PHP process that boots Laravel once and handles thousands of requests before recycling. That single architectural shift makes JIT and preloading dramatically more valuable.

OPcache Preloading in Laravel

Preloading compiles a set of PHP files into shared memory at server start. Every worker process inherits that memory map, skipping file I/O and compilation entirely for preloaded classes.

Create a preload script at bootstrap/preload.php:

<?php

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

// Preload the framework kernel and heavy Eloquent internals
$files = [
    __DIR__ . '/../vendor/laravel/framework/src/Illuminate/Foundation/Application.php',
    __DIR__ . '/../vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php',
    __DIR__ . '/../vendor/laravel/framework/src/Illuminate/Http/Request.php',
    // Add your own high-traffic domain classes here
];

foreach ($files as $file) {
    opcache_compile_file($file);
}

Then wire it in php.ini:

opcache.enable=1
opcache.preload=/var/www/bootstrap/preload.php
opcache.preload_user=www-data
opcache.memory_consumption=256
opcache.max_accelerated_files=20000

Tip: Keep the preload list focused. Preloading every vendor file inflates shared memory and can hurt cold-start time on low-memory hosts.

Enabling JIT Tracing

PHP's JIT compiler operates in two modes: function (simpler, lower overhead) and tracing (follows hot execution paths across call boundaries). For Laravel's request cycle — which is heavy on method dispatch and Eloquent hydration — tracing JIT consistently outperforms function JIT.

opcache.jit=tracing
opcache.jit_buffer_size=128M

The tracing shorthand maps to the numeric value 1254. Avoid on (which maps to function JIT) in production Laravel apps.

FrankenPHP Worker Mode

FrankenPHP's worker mode is configured via a Caddyfile or environment variable:

{
    frankenphp
}

localapp.test {
    root * /var/www/public
    php_server {
        worker /var/www/public/index.php
        num 8
    }
}

The worker directive points to your Laravel entry point. FrankenPHP calls it once, then loops internally. Laravel's service container, config, and routes are resolved on the first invocation only.

Your public/index.php needs a small adaptation to signal the loop:

<?php

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

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

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

// FrankenPHP worker loop
while ($request = \FrankenPHP\acceptRequest()) {
    $laravelRequest = Illuminate\Http\Request::capture();
    $response = $kernel->handle($laravelRequest);
    $response->send();
    $kernel->terminate($laravelRequest, $response);
}

State Leakage — The Real Risk

Long-lived workers share in-process state. Common pitfalls:

  • Static properties on service classes that accumulate per-request data.
  • Singleton bindings that cache user-specific data (e.g., a CurrentTenant singleton resolved from the request).
  • Eloquent global scopes registered conditionally during a request.

Audit every singleton in your AppServiceProvider. Prefer scoped() bindings for anything touched by request context — FrankenPHP respects Laravel's scoped container reset between requests when you call $kernel->terminate().

Practical Throughput Expectations

Without fabricating benchmarks: the combination of worker mode + preloading eliminates the two most expensive phases of a PHP request (autoload resolution and container bootstrap). JIT tracing adds measurable gains for CPU-bound work like collection transformations and complex Eloquent hydration, but has minimal impact on I/O-bound endpoints. Profile with Blackfire before and after to identify where your specific application benefits most.

Key Takeaways

  • Use tracing JIT (opcache.jit=tracing) for Laravel, not function JIT.
  • Keep preload scripts targeted — preloading everything wastes shared memory.
  • FrankenPHP worker mode eliminates per-request bootstrap cost; pair it with scoped() bindings to avoid state leakage.
  • Call $kernel->terminate() in the worker loop to trigger request lifecycle cleanup.
  • Profile before optimising — JIT gains are CPU-bound; most Laravel apps are I/O-bound.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does FrankenPHP worker mode work with Laravel Octane?
Yes. Laravel Octane added official FrankenPHP support. Using Octane's FrankenPHP driver gives you the worker loop, state reset helpers, and the Octane request lifecycle hooks without manually adapting index.php.
Q02 Is JIT worth enabling if my Laravel app is mostly database-bound?
Probably not in isolation. JIT accelerates CPU-bound PHP execution. If your profiler shows most time in database queries or external HTTP calls, focus on query optimisation and caching first. JIT is most impactful for collection-heavy transformations and complex domain logic.
Q03 How do I safely preload classes that depend on the Laravel container?
Use opcache_compile_file() rather than require. This compiles the file into bytecode without executing it, so classes are available in the opcode cache without triggering any constructor or service-provider side effects at preload time.

Continue reading

More Articles

View all