HostiServer
2026-06-04 15:11
PHP Server Optimization in 2026: PHP-FPM, OPcache, JIT and Profiling
PHP server optimization in 2026: PHP-FPM, OPcache, JIT and profiling
PHP launched in 1995 as a set of Perl CGI scripts for tracking page visitors. Thirty years later, it serves about 76% of the entire web (W3Techs, 2026) — from a single person running a WordPress blog to Slack, Wikipedia, and Etsy. No other language has come this far while preserving so much backward compatibility: code written in 2010 will, in the vast majority of cases, run on PHP 8.4 without changes.
What did change dramatically is how PHP runs on the server. In 2010 the standard was mod_php in Apache: one Apache process = one PHP interpreter, slow, RAM-hungry, but simple. In 2026 the standard is PHP-FPM with a dedicated worker pool, OPcache with precompiled bytecode in memory, and a JIT compiler generating machine instructions for hot code paths. Combined, this delivers 5–10× better performance than mod_php — but it requires tuning.
The problem is that apt packages ship PHP with conservative defaults — values chosen so it would run on a 512 MB VPS and on a 64 GB server alike. As a result, 90% of servers on the internet run with configs that don't match their hardware at all. This article is about fixing that — without academic theory, with concrete numbers and real examples for PHP 8.4 on Ubuntu 24.04 LTS.
ℹ️ How this overlaps with our other materials: the basics of installing PHP, Nginx, and Apache are covered in detail in "How to host a PHP website". The fine points of tuning for high load (CPU vs cores, NVMe vs SATA, FastCGI micro-caching) — in "Server configuration for high-traffic websites". This article is what sits between them: the actual tuning of FPM, OPcache, JIT, profiling, and PHP-specific security.
Step one: finding what you actually need to edit
Before any optimization, you need to know exactly which PHP configuration files your server is reading. On Ubuntu, several versions can live side by side (PHP 8.1, 8.2, 8.3, 8.4 install in parallel), and more often than not your changes end up in the wrong place.
Commands that give a definitive answer
php -r 'echo php_ini_loaded_file() . PHP_EOL;'
# Prints the path to the main php.ini for CLI
php-fpm8.4 -i | grep "Loaded Configuration"
# Path to php.ini for FPM (usually /etc/php/8.4/fpm/php.ini)
php -r 'echo phpversion() . PHP_EOL;'
# PHP version used by CLI
systemctl status php8.4-fpm
# Whether the FPM service is running and which version
⚠️ Common pitfall: the CLI config (/etc/php/8.4/cli/php.ini) and the FPM config (/etc/php/8.4/fpm/php.ini) are different files. If you edit the CLI config but your site runs through FPM, the changes won't apply. phpinfo() opened in a browser will show exactly the php.ini that serves the web.
PHP directory layout on Ubuntu
| Path | What lives there |
|---|---|
/etc/php/8.4/fpm/php.ini |
Main config for web requests (FPM) |
/etc/php/8.4/cli/php.ini |
Config for CLI (composer, artisan, wp-cli) |
/etc/php/8.4/fpm/pool.d/www.conf |
FPM pool config — the most important file for tuning |
/etc/php/8.4/mods-available/ |
Extension configs (opcache.ini, redis.ini, etc.) |
/var/log/php8.4-fpm.log |
FPM logs (slow log, worker startup errors) |
If you're on a different PHP version, substitute its number for 8.4. On older Ubuntu (18.04, 20.04) the paths may start with /etc/php5 or /etc/php/7.x.
PHP-FPM Pool: the key to handling load
The FPM pool is how many concurrent PHP processes the server can serve. Set wrong, the site either "falls over" from a worker shortage or "kills" the server through RAM exhaustion. This is the single most important config for performance — and the one where the biggest mistakes are made.
Three modes: dynamic / static / ondemand
| Mode | How it works | Who it's for |
|---|---|---|
dynamic |
FPM keeps N workers ready, spawns new ones on demand up to max, kills idle ones | Most sites — a balance of RAM and system responsiveness |
static |
FPM keeps exactly pm.max_children workers at all times |
High-load sites with steady traffic, where RAM economy isn't critical |
ondemand |
Workers are created only when there's a request, die after a timeout | Low-traffic sites on a VPS with little RAM |
How to calculate pm.max_children
This is the number that affects stability most. The formula:
pm.max_children = (Total RAM − RAM used by the system) / Average RAM per PHP process
In practice, one PHP process running WordPress with a typical plugin set eats 50–80 MB of RAM while processing a request. For Laravel apps — 80–120 MB. Magento 2 — 200–300 MB. To measure precisely:
ps -ylC php-fpm8.4 --sort:rss | awk '{sum+=$8; count++} END {print "Average:", sum/count/1024, "MB"}'
⚠️ Always leave a safety buffer — 10–15% off the calculated pm.max_children to account for sudden spikes in memory consumption by individual workers. If the formula gives 25, set 20–22. Otherwise, at peak traffic all workers will simultaneously eat the remaining RAM, and Linux's OOM-killer will start killing processes — sometimes MySQL or Nginx, because they may have higher OOM-scores.
Example calculations for typical servers
| Server | Available for PHP | RAM per process | pm.max_children |
|---|---|---|---|
| VPS 2 GB RAM, WordPress | 1.2 GB (MySQL+Nginx eat ~800 MB) | 60 MB | 17 (formula gives 20, minus 15% buffer) |
| VPS 4 GB RAM, WooCommerce | 2.5 GB | 100 MB | 22 (formula gives 25, minus buffer) |
| VPS 8 GB RAM, Laravel API | 5.5 GB | 100 MB | 48 (formula gives 55, minus buffer) |
| Dedicated 32 GB, Magento 2 | 24 GB (much of the rest goes to Redis and MySQL) | 250 MB | 82 (formula gives 96, minus buffer) |
Optimal pool config for dynamic mode
File /etc/php/8.4/fpm/pool.d/www.conf:
; Basic pool settings
pm = dynamic
pm.max_children = 25
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500
; Slow log — records requests slower than 5 seconds
slowlog = /var/log/php8.4-fpm-slow.log
request_slowlog_timeout = 5s
; Status page for monitoring — view from 127.0.0.1
pm.status_path = /fpm-status
ping.path = /fpm-ping
ℹ️ What each parameter does:
pm.start_servers— how many workers to start when FPM bootspm.min_spare_servers— minimum idle (ready-to-serve) workerspm.max_spare_servers— maximum idle workers (anything above gets killed)pm.max_requests = 500— a worker serves 500 requests and restarts. Prevents memory leaks in third-party plugins
Slow log — an underrated tool
The request_slowlog_timeout setting tells FPM to log a trace of any request that takes longer than the specified time. This lets you find bottlenecks without installing complex profilers. A typical slow log entry:
[03-Jun-2026 14:23:15] [pool www] pid 12453
script_filename = /var/www/site/wp-cron.php
[0x00007f5e2c4a8e30] curl_exec() /var/www/site/wp-includes/class-wp-http-curl.php:155
[0x00007f5e2c4a8e30] request() /var/www/site/wp-includes/class-wp-http.php:413
It's immediately clear: the request is stuck on curl_exec() in wp-cron — most likely a plugin trying to reach an external API that isn't responding. Without slow log, this problem would stay invisible.
OPcache: one number that changes everything
OPcache is PHP's built-in bytecode cache. Without it, PHP rereads every .php file from disk on every request, parses it into bytecode, and only then executes it. With OPcache enabled, the bytecode is stored in RAM, and repeat calls to the same file read the already-compiled version.
This gives a 3–5× speedup for any PHP application without changing a single line of code. It's not a theoretical figure — it's a real difference visible on any profiler.
Check whether OPcache is enabled
php -r 'echo opcache_get_status() ? "OPcache: ON" : "OPcache: OFF"; echo PHP_EOL;'
If it's OFF — enable it. On Ubuntu the package is called php8.4-opcache (usually installed by default but disabled in the config).
A working OPcache config for production
File /etc/php/8.4/mods-available/opcache.ini:
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=2
opcache.validate_timestamps=1
opcache.save_comments=1
opcache.fast_shutdown=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
What each parameter means
| Parameter | What it does and how to choose a value |
|---|---|
memory_consumption=256 |
How much RAM to give the cache. 128 MB is enough for a small WordPress, 256 MB for a typical business site, 512 MB for Magento or a large Laravel. If too little — you'll see "opcache.max_accelerated_files reached" in logs |
interned_strings_buffer=16 |
A separate buffer for caching repeated strings. 16 MB is a normal value for most sites |
max_accelerated_files=20000 |
How many files to cache. Check in monitoring — if the limit is hit, raise it. WordPress with 30 plugins easily reaches 8,000–12,000 files |
validate_timestamps=1 |
Whether to check if a file changed on disk. 1 — check, 0 — do NOT check |
revalidate_freq=2 |
How often to check for file changes (in seconds). With validate_timestamps=0 this parameter is ignored |
An important nuance: validate_timestamps=0 in production
This is the most controversial parameter. If you set validate_timestamps=0, PHP stops checking for file changes — and any code update must be accompanied by manual OPcache clearing (php8.4-fpm reload or opcache_reset()). In exchange, performance rises another 5–10%, because there are no stat() system calls on each request.
Worth it? Depends on the deployment process. If you have a CI/CD pipeline that auto-reloads FPM after release — yes, definitely. If you update the site via FTP by hand and forget to restart the service — better to keep validate_timestamps=1.
⚠️ The OPcache paradox: after the PHP code on disk is updated, OPcache won't see the changes until FPM restarts, if validate_timestamps=0. This regularly breaks WordPress sites belonging to people who read an article titled "how to speed up PHP," copied the production settings, and didn't understand what was behind them.
JIT compiler: when to enable and when NOT to
JIT (Just-In-Time compiler) arrived in PHP 8.0 and compiles "hot" code into native CPU instructions, bypassing the interpreter. Sounds like a speedup magic wand, but in practice it's more interesting than that.
Where JIT actually helps
- CPU-bound computations: image rendering, cryptography, parsing large data structures, mathematical algorithms. Here JIT can deliver a 1.5–3× speedup.
- CLI scripts: long data-processing jobs, background calculations, scientific work.
Where JIT doesn't help, and sometimes hurts
- Typical web apps (WordPress, Drupal, Laravel): they are I/O-bound (most of the time is spent waiting on DB queries, file reads, API responses). The JIT speedup is in the 1–3% range, often invisible against normal variance.
- Sites with segfaults: JIT in some cases conflicts with third-party extensions. If worker crashes appear in the logs after enabling — that's the first hypothesis.
JIT configuration
JIT is controlled by two parameters in opcache.ini:
opcache.jit=tracing
opcache.jit_buffer_size=128M
| opcache.jit value | What it means |
|---|---|
tracing |
The most aggressive mode, compiles "hot" loops and functions. Best performance |
function |
Compiles individual functions. Softer mode, less risk |
disable or 0 |
JIT disabled |
Recommendation: enable tracing, measure performance before and after, keep it on only if the difference is noticeable. On a site with a typical CMS workload, JIT may just eat 128 MB of RAM and give nothing in exchange.
How to measure the effect of JIT
The simplest way is two tools together: Apache Bench for a load test and monitoring via top:
# Test without JIT (first disable JIT and reload FPM)
ab -n 1000 -c 10 https://yoursite.com/
# Test with JIT (enable it, reload FPM)
ab -n 1000 -c 10 https://yoursite.com/
# Compare "Requests per second" and "Time per request"
If the difference is under 5%, JIT is economically pointless for your case — you can disable it and return 128 MB of RAM to the system.
Realpath cache: a small parameter that saves your SSD
A parameter that's rarely discussed but has a disproportionately large effect on sites with lots of files (WordPress with 50+ plugins, Magento, large Laravel projects).
Every time PHP includes a file via require or include, it resolves the path through a series of stat() system calls. For a relative path like ../vendor/symfony/finder/Finder.php, that means several dozen calls to the filesystem. The realpath cache stores already-resolved paths in memory.
realpath_cache configuration
In php.ini:
realpath_cache_size = 4096K
realpath_cache_ttl = 600
The default is 256K — enough for about 150 cached paths. For WordPress with a reasonable plugin set you need at least 2–4 MB. Check current usage:
php -r 'echo realpath_cache_size() / 1024 . " KB used\n";'
# How many bytes the cache actually uses at this moment
php -r 'print_r(realpath_cache_get());'
# A full list of all cached paths (useful for diagnostics)
If the number from the first command is close to the realpath_cache_size value in php.ini — the cache is overflowing, raise the limit.
💡 From experience: on a WordPress site with 40 plugins and WooCommerce, raising realpath_cache from 256K to 4M cuts page generation time by about 15–20% from path caching alone. A hidden win often missed in the race for OPcache and Redis.
Profiling: finding what's actually slow
Up to this point we've been tuning by guessing: bumped OPcache, bumped the pool, enabled JIT. But real optimization begins when you see exactly where the code spends time. Without profiling, you're not tuning your site — you're tuning an abstract "average site".
Tools, in order of complexity
| Tool | What it's for | Complexity |
|---|---|---|
| FPM slow log | Find slow requests without extra tooling | Low |
| OPcache GUI (opcache-gui) | See how OPcache is being used, hit-ratio statistics | Low |
| Xdebug profiler | Detailed profiling in cachegrind format, opens in KCachegrind | Medium |
| Blackfire / Tideways | Production-ready APM, works with minimal overhead | High (commercial) |
| strace / perf | System level — what PHP is doing at the OS level | High |
Where to start: OPcache GUI
OPcache GUI is a small .php script that shows detailed cache-usage statistics. Installs in a minute:
cd /var/www/site/
wget https://raw.githubusercontent.com/amnuts/opcache-gui/master/index.php -O opcache-status.php
Then open https://yoursite.com/opcache-status.php in a browser — you'll see memory usage, hit ratio, the number of cached files, and a list of what's in the cache. If the hit ratio is below 99% — that's a signal that memory_consumption is too low.
🚨 Don't leave opcache-status.php publicly accessible. It exposes the filesystem layout, the PHP version, and the list of all plugins — information that significantly helps an attacker. Either protect it with an nginx allow/deny rule from your office IP, or delete it after the check.
Xdebug profiler — fine-grained profiling
Xdebug is the best-known PHP debugger and profiler. For profiling, it's used in profile mode:
; In /etc/php/8.4/mods-available/xdebug.ini
xdebug.mode = profile
xdebug.output_dir = /tmp/xdebug
xdebug.start_with_request = trigger
Then add ?XDEBUG_TRIGGER=1 to the URL — Xdebug will write a call profile to /tmp/xdebug. Open the resulting cachegrind.out file in KCachegrind or QCachegrind — you'll see a call hierarchy with times, percentages, and call counts.
⚠️ Don't leave Xdebug in profile mode on production permanently. It adds 20–100% overhead to execution time. Use it only while diagnosing, then switch off or set xdebug.mode = off.
PHP-specific security: what other articles don't cover
General server security (HTTPS, fail2ban, security headers) is covered in detail in our article on protecting your site from hackers. Here — strictly the PHP level: php.ini parameters that close PHP-specific attack vectors.
expose_php = Off
By default PHP adds an X-Powered-By: PHP/8.4.X header to every response. That's free intel for an attacker: they know your exact version and can hunt for matching CVEs. Disabling it is simple:
expose_php = Off
Verify after an FPM restart:
curl -I https://yoursite.com/ | grep -i powered
# If nothing prints — the header is gone
disable_functions: what's actually worth disabling
This is a list of PHP functions that will never execute. If a plugin or vulnerability tries to escape to a shell through them, PHP just throws an error. A sensible minimum for most sites:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source,phpinfo
⚠️ Catch: some CMSes and plugins use these functions legitimately. WordPress may use proc_open for updates. Magento uses shell_exec in some scenarios. Test on a staging copy before applying. If something breaks, remove functions from the list one at a time until you find the culprit.
open_basedir: isolating PHP from the filesystem
This parameter restricts which directories a PHP script can access. Even if an attacker uploads a shell to /uploads, they won't be able to read /etc/passwd or /var/log through it:
open_basedir = /var/www/yoursite/:/tmp/
Everything outside those directories becomes unreachable to functions like file_get_contents, fopen, include. One of the strongest PHP-level protections — and one that often gets skipped.
allow_url_fopen = Off (where possible)
By default PHP allows opening URLs the same way as files: file_get_contents('https://...'). An attacker can use this for:
- SSRF (Server-Side Request Forgery) — force the server to make requests to internal resources
- Remote File Inclusion — include malicious PHP code from an external server
If your code doesn't access external URLs via file_get_contents (and instead uses cURL or Guzzle, as is standard in modern applications) — disable it:
allow_url_fopen = Off
allow_url_include = Off
allow_url_include should always be disabled — it's a dedicated vector for Remote File Inclusion and in 99% of cases nobody needs it.
session.cookie_httponly, session.cookie_secure
PHP session settings. Without them, JavaScript on the page can read the session cookie (via an XSS vulnerability, for example), or the session cookie may be transmitted over HTTP bypassing HTTPS:
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Strict"
session.use_strict_mode = 1
Top configuration mistakes I see on other people's servers
- memory_limit = -1 "just in case". That means "no limit". A single runaway script can eat all the server's RAM. It should be a specific number: 256M, 512M, 1024M.
- max_execution_time = 0. Same idea — a script with no timeout can hang for hours, holding an FPM worker. 60–90 seconds is a normal range for most sites.
- display_errors = On in production. Errors get shown to users in the browser, along with file paths, library versions, and sometimes SQL fragments. Show them only on a dev server. In production —
display_errors = Off, log_errors = On. - pm.max_children picked blindly. Either set to 5 and the site falls over on the first traffic spike, or set to 200 and the server chokes. Calculate it with the formula from the FPM section.
- OPcache with memory_consumption=64. The default value from most tutorials. For any real CMS site that's too little — the cache constantly overflows and resets. Minimum 128, realistically 256–512.
- opcache.validate_timestamps=0 without CI/CD. After deployment the site shows the old code, and admins desperately hunt for bugs. Either set it to 1, or configure automatic FPM reload after release.
- JIT with buffer_size=512M because "more = better". On a CMS site that swallows half a gig of RAM for nothing. JIT only uses what it actually needs — start with 64–128M.
- info.php / phpinfo.php left in production. A temporary file made for a check that got forgotten. An attacker sees the PHP version, all loaded modules, paths, environment variables. Delete it right after checking — we covered the danger of exposing software versions and how to hide them in our article on checking the Apache version.
🚀 A PHP server with the right configuration out of the box
Configuring FPM, OPcache, JIT, and profiling isn't complex, but it takes time and experience. On Hostiserver most of these things are already set up by default, and the rest is handled together with tech support, tuned to your specific project and load.
💻 Cloud (VPS) Hosting
- From $19.95/mo — KVM isolation, full root access
- PHP 8.4 and 8.5 with an optimized php.ini for CMSes and frameworks out of the box
- OPcache and JIT enabled and tuned for your workload profile
- PHP-FPM pool tuning — help calculating pm.max_children for your RAM and CMS
- Slow log and monitoring — engineers will help find bottlenecks in your code
- 24/7 engineering support — <10 min response, real DevOps engineers
🖥️ Dedicated Servers
- From $90/mo — full isolation, resources reserved entirely for your project
- High-load tuning — Magento, WooCommerce, large Laravel projects
- Free migration from another provider with configuration review
- Profiling and audit of your PHP stack during onboarding
- 99.9% uptime SLA guaranteed in the contract
💬 Not sure which option fits you?
💬 Drop us a line and we'll help you figure it out!
Frequently asked questions
- Which PHP version should I upgrade to in 2026?
The minimum supported version is PHP 8.3 (support ends late 2026). The sweet spot for most projects is PHP 8.4 — stable, with active security maintenance. PHP 8.5 is the current release for those who follow the schedule and want new language features (property hooks, asymmetric visibility). PHP 8.6 is expected by year's end. Anything below PHP 8.2 no longer gets security patches and carries risk.
- Is it worth switching from mysqli to PDO?
Both extensions are supported and not deprecated. PDO has the edge if portability between databases matters to you (MySQL, PostgreSQL, SQLite via one codebase) or you appreciate the convenience of prepared statements with named parameters. mysqli stays 5–10% faster for pure MySQL queries and has some MySQL-specific features (multi-query, async queries). For new code I recommend PDO; for existing code, not worth rewriting just for the sake of switching. Separately, server-side database protection and user configuration are covered in our article on MySQL security on hosting.
- Which is the priority: OPcache or Redis for caching?
They're different layers of cache. OPcache caches compiled bytecode — without it PHP reads and parses .php files on every request. Redis caches application data — SQL query results, rendered templates, objects. OPcache should always be on — that's the baseline. Redis is added after OPcache, once you see the bottleneck is DB queries. It's not "one instead of the other," it's "both, in sequence".
- How do I know whether the server is holding the load or needs to be upgraded?
Several markers. The FPM log shows "WARNING: server reached pm.max_children setting" — the pool is full, you either upgrade RAM and raise max_children or hunt down why requests are slow. In
top, the Load Average approaches or exceeds the core count — the server is at the limit. The slow log regularly records requests >5 seconds — there's code that needs profiling.opcache_get_status()shows hit_ratio under 99% — OPcache is too small. If all of this is curable by tuning, you don't need an upgrade. If the parameters are already maxed out, time to move to a bigger plan; we covered the choice between VPS, dedicated, and managed servers in a separate article on choosing a server type.
- Is anything special needed for Laravel or Symfony?
The base PHP stack is the same. Framework-specific tuning comes down to four things. First, OPcache preloading (PHP 7.4+) — for Laravel and Symfony it gives a noticeable speedup because the framework core is loaded into memory once at FPM startup. Second, the framework's own configuration cache:
artisan config:cache,artisan route:cache,artisan view:cachefor Laravel;console cache:warmup --env=prodfor Symfony. Third, make sureAPP_DEBUG=falsein production — otherwise all errors are shown to clients and the debug mode itself seriously slows things down. Fourth,memory_limitfor CLI should be higher than for FPM — console commands (migrations, queues, profile generation, data exports) often need 512M–1G, whereas web requests are happy with 256M.
- Why does the site show old code after I updated it?
Almost always — OPcache with
validate_timestamps=0or a largerevalidate_freq. PHP caches the compiled bytecode and doesn't notice that the files on disk have changed. Fix:sudo systemctl reload php8.4-fpmafter every release, or callopcache_reset()from a PHP script (can be wired into CI/CD as a hook), or setvalidate_timestamps=1with a smallrevalidate_freq(2 seconds, for example — a minimal performance penalty and automatic picking up of changes).
- Is it safe to enable JIT on a production server?
Almost always yes, but with caveats. JIT in PHP 8.4 is stable and used in production by major companies. But there are situations where it's better not to enable it: if you have third-party binary extensions (especially homegrown ones) that haven't been tested with JIT; if worker segfaults appear in the logs after enabling; if you see that your site is I/O-bound (most of its time in DB queries) — JIT will just eat RAM with no return. Standard advice: enable on staging, run a load test, monitor — if everything's fine for a week, roll it out to production.