DEV Community

Cover image for PHP JIT in 2026: When It Helps (Spoiler: Rarely the Web Path)
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP JIT in 2026: When It Helps (Spoiler: Rarely the Web Path)


A team I talked to last quarter flipped on opcache.jit=tracing in production, expected a 20% win across the board, and got nothing. Their p99 didn't move. Their CPU graphs didn't budge. Their checkout page still spent 180ms in MySQL.

That's the trap. PHP JIT shipped with 8.0 in late 2020. The Zend benchmark went from "PHP is slow" to "PHP is fast" overnight. Then everyone read the headline, turned it on for their Laravel app, and found out the headline was about a tight bench.php loop computing Mandelbrot sets. Not a web request that's 95% waiting on Postgres.

Five years in, the answer is more honest. JIT helps. Just not where you'd guess.

What JIT actually does

OPcache, the bytecode cache PHP has shipped since 5.5, parses your .php files into Zend opcodes once and caches them. Every request after the first reuses the cached opcodes. That's already a huge win, but the opcodes still run through the Zend VM, which is a software interpreter dispatching on every operation.

JIT goes one step further. It takes hot opcodes, compiles them to native x86_64 or ARM64 machine code at runtime, and runs that machine code directly. No interpreter dispatch, no virtual instruction decode, just CPU instructions hitting registers.

PHP ships two JIT strategies. function JIT compiles entire functions when they're called often enough. tracing JIT watches which code paths actually run hot at runtime and compiles those traces. Tracing is smarter. It can inline across function boundaries, drop dead branches, and specialize on observed types. It's also what the PHP team recommends as the default.

; php.ini: the only two settings that matter for 95% of teams
opcache.enable=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
Enter fullscreen mode Exit fullscreen mode

That jit_buffer_size=0 is the default and means JIT is off. You have to set a non-zero buffer for JIT to do anything. 128M is plenty for most apps; the old "64M" advice from 2020 is fine too. Anything below 32M and the JIT will quietly disable itself when the buffer fills up.

Why your Laravel checkout doesn't care

A typical Laravel web request looks like this:

  • 2ms boot (autoload, container, middleware)
  • 4ms routing + controller setup
  • 12ms ORM query building + 80ms MySQL roundtrips
  • 6ms Blade template rendering
  • 4ms response shipping

That's 108ms wall time. Of that, maybe 28ms is actually PHP doing CPU work the JIT could speed up. The other 80ms is your database. JIT does nothing for database time. It does nothing for curl calls to Stripe. It does nothing for Redis. It does nothing for the file system.

Worse, web requests are short-lived under php-fpm. Each request gets a fresh worker context, the JIT warms up, and then the request ends. Tracing JIT pays a profiling cost on first run to figure out which paths are hot. By the time it's compiled your hot loop, the request is done. Next request, same drill.

The JIT cache survives across requests in the SHM (shared memory) segment, so the second request through the same code path does benefit. But here's the gotcha: if your traffic spreads across hundreds of routes, the JIT buffer fills up with warm code that's not hot enough to help any single request.

This is why every Laravel JIT benchmark you see on Twitter shows a 2-4% improvement. That's noise.

Case 1: CPU-bound math

JIT wins hardest when PHP is doing actual math. Image manipulation without an extension. Compression. Cryptography in pure PHP. Statistical work. Tight loops doing arithmetic.

Here's a Mandelbrot benchmark. Small, but real:

<?php
// mandel.php: classic CPU-bound benchmark
declare(strict_types=1);

function mandelbrot(int $width, int $height, int $maxIter): int {
    $sum = 0;
    for ($py = 0; $py < $height; $py++) {
        $y0 = ($py / $height) * 2.0 - 1.0;
        for ($px = 0; $px < $width; $px++) {
            $x0 = ($px / $width) * 3.5 - 2.5;
            $x = 0.0;
            $y = 0.0;
            $iter = 0;
            while ($x * $x + $y * $y <= 4.0 && $iter < $maxIter) {
                $xtemp = $x * $x - $y * $y + $x0;
                $y = 2.0 * $x * $y + $y0;
                $x = $xtemp;
                $iter++;
            }
            $sum += $iter;
        }
    }
    return $sum;
}

$start = hrtime(true);
$result = mandelbrot(800, 600, 200);
$elapsed = (hrtime(true) - $start) / 1e9;
printf("result=%d  elapsed=%.3fs\n", $result, $elapsed);
Enter fullscreen mode Exit fullscreen mode

On a 2024 MacBook Pro M3 with PHP 8.4.3:

$ php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=0 mandel.php
result=20374691  elapsed=4.812s

$ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M mandel.php
result=20374691  elapsed=1.043s
Enter fullscreen mode Exit fullscreen mode

That's a 4.6× speedup. Same code, same machine, same PHP binary. The JIT compiled the inner while loop to native arithmetic and the interpreter dispatch overhead vanished.

This is what every "PHP is fast now" headline is showing you. It's real. It's also nothing like your Laravel controller.

Case 2: Long-running CLI workers

This is the case most teams miss. Queue workers. Batch jobs. Anything that boots once and runs for hours.

A Laravel php artisan queue:work process is the perfect JIT host. It boots, the autoloader hits, the framework warms up, and then it sits in a loop pulling jobs forever. Every job goes through the same dispatch path, the same middleware, the same serializer. Tracing JIT identifies these hot paths once, compiles them, and reuses the compiled code for every subsequent job.

The wins compound. A team running 40 queue workers processing 10M jobs a day reported a 22% drop in CPU time after enabling opcache.jit=tracing. They didn't change a single line of application code. They cut two EC2 instances from their worker fleet.

Same logic applies to:

  • Symfony Messenger consumers
  • Long-running CLI tools you wrote yourself (data migrations, report generators)
  • Octane / RoadRunner / FrankenPHP workers, which already keep PHP processes alive across requests

The pattern: if your PHP process lives long enough for the JIT to warm up and runs the same code path many times, JIT pays.

PHP JIT decision tree: CLI worker hot loop benefits, web request mostly doesn't

Case 3: Library code that runs everywhere

The third winner is library code your app hits on every request, but where the library itself is doing CPU work that's worth compiling.

Twig's template compiler. Doctrine's ORM hydration. The regex engine in pure-PHP libraries. The serializer paths in Symfony components. JMS Serializer. The internals of phpunit when it runs your test suite.

This is sneakier because the speedup is per-library, not per-app. You don't see "20% faster." You see "Twig rendering is now 1.8× faster, Doctrine hydration is 1.4× faster, and your overall request is 6% faster because those two pieces were 30% of your CPU."

The 6% is real. It's just unglamorous.

Tuning the buffer and the mode

The defaults are fine for almost everyone. The two knobs that matter:

; the actual two settings 95% of teams need
opcache.jit=tracing
opcache.jit_buffer_size=128M
Enter fullscreen mode Exit fullscreen mode

opcache.jit=tracing is the smart mode. Use this. There's also function, which is simpler and compiles whole functions. Tracing is better in nearly every real workload.

opcache.jit_buffer_size=128M gives the JIT room to store compiled code. Too small and the JIT silently disables itself once the buffer fills. The php.ini default of 0 means JIT is off entirely, which trips up teams who set opcache.jit=tracing and wonder why nothing changed.

You'll also see fine-grained mode strings like opcache.jit=1255 in older docs. That four-digit format (CRTO: CPU register allocator, register policy, trigger, optimization level) still works but is harder to reason about. Stick with tracing unless you've got a specific reason.

Two things that look like tuning knobs but mostly aren't:

opcache.jit_hot_loop and opcache.jit_hot_func set the thresholds for what counts as "hot." Defaults are tuned by the PHP team against real workloads. Don't touch unless you're profiling.

opcache.jit_max_root_traces caps how many trace entry points the JIT will compile. The default of 1024 is generous.

The blacklist trick

One thing worth knowing: you can tell the JIT to skip specific files. This matters when a particular library trips on the JIT. Rare in PHP 8.4, but it happens, especially with extensions that hook into the Zend engine.

opcache.jit_blacklist=/etc/php/opcache-jit-blacklist.txt
Enter fullscreen mode Exit fullscreen mode
# /etc/php/opcache-jit-blacklist.txt
# one regex per line; matched against the full file path

# skip a vendor library that segfaults with tracing JIT
/var/www/app/vendor/some-vendor/some-package/src/.*\.php

# skip a single problematic file
/var/www/app/app/Legacy/HandRolledStateMachine\.php
Enter fullscreen mode Exit fullscreen mode

If you hit a Fatal error: Allowed memory size... in unknown on line 0 or a SIGSEGV that appears only with JIT on, blacklist the offending file and report the bug upstream. It's how you keep JIT enabled across your app while quarantining one bad path.

Measuring it on your own workload

The only honest way to know if JIT helps you is to measure. Forget the blog benchmarks. They're not your app.

The minimum useful experiment:

  1. Pick your slowest endpoint and your hottest queue job
  2. Run them under load with opcache.jit_buffer_size=0 (JIT off)
  3. Run them again with opcache.jit=tracing and opcache.jit_buffer_size=128M
  4. Compare p50, p95, p99 (not averages, the distribution)

For web requests, use wrk or k6 against staging. For queue workers, dispatch a known batch and time the drain.

PHP also exposes JIT internals via opcache_get_status():

<?php
$status = opcache_get_status(false);
print_r($status['jit'] ?? 'JIT disabled');
Enter fullscreen mode Exit fullscreen mode

You'll see something like:

Array
(
    [enabled] => 1
    [on] => 1
    [kind] => 5
    [opt_level] => 4
    [opt_flags] => 6
    [buffer_size] => 134217712
    [buffer_free] => 91423808
)
Enter fullscreen mode Exit fullscreen mode

If buffer_free is close to zero, the JIT buffer is full and new code isn't being compiled. Bump jit_buffer_size. If buffer_free is barely below buffer_size, the JIT isn't finding much to compile. That's your hint that the workload isn't CPU-bound enough to benefit.

The takeaway

JIT isn't magic. It's a tool with a narrow sweet spot.

Turn it on for queue workers, CLI batch jobs, anything CPU-bound, and any Octane-style long-lived process. Expect 15-30% wins there. Turn it on for the web tier too. It costs nothing extra and helps the library-code path. Just don't expect a graph that goes vertical. Your p99 lives in MySQL and S3, and JIT doesn't reach either.

The teams who get the most out of JIT are the ones who measure first, target the worker fleet, and don't read the Twitter benchmark as a license to expect 4× on every request.


If this was useful

PHP JIT is one of those runtime decisions that compounds with how your code is shaped. CPU-bound math in a thin domain layer wins more than the same logic tangled through ORM hooks and framework middleware. The architectural layer your codebase reaches for after it outgrows the framework defaults is the topic of Decoupled PHP: clean and hexagonal architecture for applications that outlive the framework they were born in. If your JIT wins are getting eaten by accidental complexity, the book's about exactly that gap.

What's your current OPcache + JIT setup, and have you measured what it actually buys you?

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)