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

4 min read Mohamed Said Mohamed Said

Why the PHP Runtime Layer Still Matters

Most Laravel performance conversations stop at query optimization or Redis caching. The runtime layer — how PHP itself boots, compiles, and executes your code — is often left at defaults. FrankenPHP, OPcache JIT, and preloading each attack a different slice of that cost.


OPcache and JIT: What They Actually Do

OPcache caches the compiled opcode of every PHP file so subsequent requests skip parsing and compilation. JIT (Just-In-Time compilation) goes one step further: it compiles hot opcode paths to native machine code at runtime.

For Laravel, the practical gain from JIT is modest on I/O-bound routes (most web requests) but measurable on CPU-bound work — think report generation, data transformation pipelines, or heavy collection operations.

; 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

; JIT — tracing mode is the most effective for web workloads
opcache.jit=tracing
opcache.jit_buffer_size=128M

opcache.validate_timestamps=0 is critical in production. With it enabled, OPcache stat-checks every file on every request, which defeats much of the benefit.


Preloading: Boot Laravel Once, Serve Forever

Preloading (PHP 7.4+) lets you load and compile classes into shared memory before any request is served. Every worker process inherits that memory map, skipping file I/O and compilation for preloaded classes entirely.

Laravel ships with a preload script you can publish:

php artisan vendor:publish --tag=laravel-preload

This generates storage/framework/preload.php. Point OPcache at it:

opcache.preload=/var/www/html/storage/framework/preload.php
opcache.preload_user=www-data

The generated script uses Illuminate\Foundation\Application::preload() to walk the framework's class map. You can extend it with your own hot paths:

// storage/framework/preload.php
require __DIR__ . '/../../../vendor/autoload.php';

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

// Preload your most-hit domain classes
require_once __DIR__ . '/../../../app/Domain/Pricing/PriceCalculator.php';
require_once __DIR__ . '/../../../app/Http/Resources/ProductResource.php';

Keep the preload list focused. Preloading everything inflates shared memory and can actually hurt cold-start time on low-traffic servers.


FrankenPHP: Eliminating the FPM Boundary

FrankenPHP is a PHP app server written in Go that embeds the PHP interpreter directly. It replaces Nginx + PHP-FPM with a single binary. The key architectural difference: no Unix socket or TCP hop between the web server and PHP.

Worker Mode — The Real Win

FrankenPHP's worker mode is analogous to Laravel Octane's approach: PHP boots once, then handles requests in a loop without re-bootstrapping the framework.

# Dockerfile (production)
FROM dunglas/frankenphp:latest-php8.3

COPY . /app
WORKDIR /app

RUN install-php-extensions pdo_pgsql redis intl

CMD ["frankenphp", "run", "--config", "/app/Caddyfile"]
# Caddyfile
{
    frankenphp
    order php_server before file_server
}

localhost {
    root * /app/public
    php_server {
        worker /app/public/index.php
        num 4
    }
}

The worker directive tells FrankenPHP to keep index.php alive. Laravel's bootstrap runs once per worker, not once per request.

State Leakage in Worker Mode

The same caution that applies to Octane applies here. Singletons bound in the container survive across requests. Reset them explicitly:

// public/index.php (worker entry point)
require __DIR__ . '/../vendor/autoload.php';

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

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

while ($request = \FrankenPHP\handle_request()) {
    $response = $kernel->handle(
        $illuminateRequest = Illuminate\Http\Request::createFromGlobals()
    );
    $response->send();
    $kernel->terminate($illuminateRequest, $response);
}

If you use Laravel Octane's RequestHandled flush hooks, those translate cleanly here.


Combining All Three

The layers are additive:

  1. OPcache — eliminates parse/compile overhead on every request.
  2. Preloading — moves framework class loading to server boot, not request time.
  3. JIT — compiles hot opcode to native code for CPU-bound paths.
  4. FrankenPHP worker mode — eliminates framework bootstrap cost per request entirely.

Key Takeaways

  • Set opcache.validate_timestamps=0 in production; use deployment hooks to clear the cache on deploy.
  • JIT's biggest gains are on CPU-bound code; don't expect dramatic wins on typical CRUD routes.
  • Keep your preload list to genuinely hot classes — framework core plus your own domain hotspots.
  • FrankenPHP worker mode and Octane share the same state-leakage risks; audit your singletons before enabling either.
  • All three optimizations are orthogonal and compose cleanly — enable them incrementally and profile between each change.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need Laravel Octane if I'm already using FrankenPHP worker mode?
Not necessarily. FrankenPHP worker mode achieves the same core benefit as Octane — keeping the framework bootstrapped between requests. Octane adds conveniences like table caches and explicit flush callbacks, but the raw performance mechanism is equivalent. You can use Octane's FrankenPHP driver to get both.
Q02 Will OPcache JIT break any Laravel application code?
In practice, JIT is transparent to application code — it operates at the opcode level. The rare issues reported historically involved specific ext-* extensions or code that relied on precise memory layout, neither of which is a concern for standard Laravel applications.
Q03 How do I clear the preload cache after a deployment?
Preloaded classes live in shared memory tied to the PHP-FPM or FrankenPHP worker process. A graceful worker restart (e.g., `systemctl reload php8.3-fpm` or sending SIGUSR2 to FrankenPHP) clears and rebuilds the preload cache from the updated files.

Continue reading

More Articles

View all