Performance

Optimizing PHP Performance: Profiling, OPcache, and JIT

Profile and optimize PHP applications using Xdebug, Blackfire, OPcache tuning, JIT compilation, and practical code-level improvements.

PHP performance profiling flame graph with OPcache and JIT metrics

Most PHP performance problems are not in PHP. They are in unoptimized database queries, missing indexes, N+1 patterns, and unnecessary network calls. But once you have fixed those, PHP itself has significant tuning headroom through OPcache configuration, JIT compilation, and code-level improvements.

The cardinal rule: profile first, optimize second. Never guess where the bottleneck is.

Profiling: finding the actual bottleneck

Xdebug profiler

Xdebug is the standard PHP debugger. Its profiling mode generates cachegrind files that show function-level timing.

1
2
3
4
; php.ini
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.start_with_request=trigger

Trigger profiling by adding XDEBUG_PROFILE=1 to the request:

1
curl -b "XDEBUG_PROFILE=1" http://localhost/api/users

Open the resulting cachegrind file in KCachegrind (Linux), QCachegrind (macOS), or webgrind (browser-based).

Blackfire

Blackfire provides production-safe profiling with a clean web UI:

1
blackfire curl http://localhost/api/users

Blackfire shows call graphs, time percentages, and memory allocation per function. It is the most practical tool for identifying optimization targets because it works in production without significant overhead.

SPX (Simple Profiling Extension)

SPX is lightweight and built specifically for quick profiling:

1
2
3
4
; php.ini
extension=spx.so
spx.http_enabled=1
spx.http_key=dev

Access the profiling UI at http://localhost/?SPX_KEY=dev&SPX_UI_URI=/. SPX shows real-time flame graphs directly in the browser.

What to look for

  1. Functions that consume >10% of total time: These are your optimization targets
  2. Functions called thousands of times: Even if each call is fast, the aggregate cost matters
  3. Database queries: If 80% of time is in PDO/MySQLi, optimize queries, not PHP code
  4. Autoloader hits: If the autoloader shows up prominently, OPcache preloading helps

OPcache: the biggest free performance gain

OPcache caches compiled PHP bytecode in shared memory. Without it, PHP recompiles every file on every request. With it, the compilation step is skipped entirely.

Verifying OPcache is active

1
var_dump(opcache_get_status());

Key values to check:

  • opcache_enabled: must be true
  • cache_full: must be false (increase memory if true)
  • num_cached_scripts: should be close to your total PHP file count

Production configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
; Maximum memory for bytecode cache (MB)
opcache.memory_consumption=256

; Memory for interned strings (MB)
opcache.interned_strings_buffer=16

; Maximum number of files to cache
opcache.max_accelerated_files=20000

; DO NOT check file timestamps in production
opcache.validate_timestamps=0

; Save compiled code to disk for faster restart
opcache.file_cache=/tmp/opcache

; Save PHP comments (needed for annotations/attributes)
opcache.save_comments=1

; Enable huge page mapping (Linux only, needs OS config)
opcache.huge_code_pages=1

OPcache preloading

PHP 7.4+ supports preloading: load commonly used files into OPcache at server start, avoiding autoloader overhead entirely.

1
2
3
4
5
6
7
8
9
10
11
12
// preload.php
$files = [
'/var/www/vendor/autoload.php',
'/var/www/app/Models/User.php',
'/var/www/app/Models/Article.php',
'/var/www/app/Services/AuthService.php',
// ... hot paths
];

foreach ($files as $file) {
opcache_compile_file($file);
}
1
2
3
; php.ini
opcache.preload=/var/www/preload.php
opcache.preload_user=www-data

Laravel provides php artisan optimize which generates a preload file automatically.

JIT compilation

PHP 8.0+ includes a JIT (Just-In-Time) compiler that converts PHP bytecode to native machine code at runtime. This eliminates the bytecode interpretation step for hot code paths.

When JIT helps

JIT accelerates CPU-bound operations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// JIT helps here: pure computation
function fibonacci(int $n): int
{
if ($n <= 1) return $n;
return fibonacci($n - 1) + fibonacci($n - 2);
}
// ~3x faster with JIT enabled

// JIT does NOT help here: I/O-bound
function getUsers(): array
{
return DB::table('users')->get(); // 99% of time is waiting for MySQL
}
// Same speed with or without JIT

JIT configuration

1
2
3
4
5
6
7
; Enable JIT with 128MB buffer
opcache.jit_buffer_size=128M

; JIT optimization level
; 1255 = tracing JIT (recommended for most apps)
; 1205 = function JIT (more conservative)
opcache.jit=1255

JIT modes explained

The opcache.jit value is a 4-digit number CRTO:

  • C (CPU optimization): 1 = enable
  • R (register allocation): 2 = use registers
  • T (trigger): 5 = tracing (profile and compile hot paths)
  • O (optimization level): 5 = maximum

1255 (tracing JIT with full optimization) is the recommended production setting.

Practical JIT impact

Workload Without JIT With JIT Improvement
Mathematical computation 100ms 35ms 2.8x
String processing loop 80ms 45ms 1.8x
JSON encoding/decoding 50ms 40ms 1.25x
Laravel HTTP request 12ms 11ms 1.1x
Database-heavy page 45ms 44ms 1.02x

For typical web applications, JIT provides 5-15% improvement. For compute-heavy scripts, it can be 2-3x.

Code-level optimizations

Avoid unnecessary object creation

1
2
3
4
5
6
7
8
9
10
11
// Slow: creates a new Carbon instance on every iteration
foreach ($orders as $order) {
$date = new Carbon($order->created_at);
// ...
}

// Faster: parse once or use the casted attribute
foreach ($orders as $order) {
$date = $order->created_at; // Already a Carbon instance if cast
// ...
}

Use generators for large datasets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Slow: loads all 100K rows into memory
function getAllUsers(): array
{
return DB::table('users')->get()->all();
}

// Better: yields one row at a time
function getAllUsers(): Generator
{
foreach (DB::table('users')->cursor() as $user) {
yield $user;
}
}

// Memory usage: ~40MB vs ~2MB

Optimize string concatenation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Slow for large strings (creates intermediate copies)
$html = '';
foreach ($items as $item) {
$html .= "<li>{$item->name}</li>";
}

// Faster: collect in array, implode once
$parts = [];
foreach ($items as $item) {
$parts[] = "<li>{$item->name}</li>";
}
$html = implode('', $parts);

// Even better in templates: use output buffering
ob_start();
foreach ($items as $item) {
echo "<li>{$item->name}</li>";
}
$html = ob_get_clean();

Cache expensive computations

1
2
3
4
5
6
7
8
9
10
11
12
13
// Without caching: hits database every request
function getSettings(): array
{
return DB::table('settings')->pluck('value', 'key')->all();
}

// With caching: hits database once per hour
function getSettings(): array
{
return cache()->remember('app.settings', 3600, function () {
return DB::table('settings')->pluck('value', 'key')->all();
});
}

Common mistakes

Optimizing without profiling: Developers often optimize code that is not the bottleneck. A 50% improvement to a function that takes 1ms is worthless when the database query above it takes 200ms.

Setting OPcache memory too low: If OPcache runs out of memory, it evicts cached scripts and recompiles them. Check opcache_get_status()['memory_usage'] to size it properly.

Enabling JIT in development: JIT makes debugging harder because stack traces may not match source lines. Use JIT in production only.

Ignoring query optimization: The biggest PHP performance gain is usually adding a database index, not tuning PHP. Run EXPLAIN on slow queries first.

FAQ

Does OPcache work with Docker?

Yes, but configure opcache.validate_timestamps=0 since files do not change in a container after build. Also consider opcache.file_cache for faster container restarts.

Should I use preloading with Laravel?

Yes, if you are on PHP 7.4+. Run php artisan optimize and configure preloading in php.ini. It reduces autoloader overhead by 10-20%.

How do I benchmark PHP performance?

Use ab (Apache Bench), wrk, or k6 for HTTP-level benchmarks. Use Xdebug or Blackfire for function-level profiling. Never benchmark with a single request—run at least 1000 requests to get stable numbers.

Next steps

Start with opcache_get_status() to verify OPcache is running and properly sized. Then profile a slow endpoint with Xdebug or Blackfire to find the actual bottleneck. Fix the biggest bottleneck first—it is almost always a database query.

For Docker-specific PHP tuning, the Dockerizing PHP apps guide covers container-level optimizations. The FrankenPHP guide covers an alternative runtime that eliminates per-request bootstrap overhead. The existing page speed guide covers frontend performance that complements server-side PHP optimization.