Community
60
HostiServer
2026-06-04 15:11

PHP Server Optimization in 2026: PHP-FPM, OPcache, JIT and Profiling

⏱️ Reading time: ~11 minutes | 📅 Updated: June 2026

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 boots
  • pm.min_spare_servers — minimum idle (ready-to-serve) workers
  • pm.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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:cache for Laravel; console cache:warmup --env=prod for Symfony. Third, make sure APP_DEBUG=false in production — otherwise all errors are shown to clients and the debug mode itself seriously slows things down. Fourth, memory_limit for 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=0 or a large revalidate_freq. PHP caches the compiled bytecode and doesn't notice that the files on disk have changed. Fix: sudo systemctl reload php8.4-fpm after every release, or call opcache_reset() from a PHP script (can be wired into CI/CD as a hook), or set validate_timestamps=1 with a small revalidate_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.

Contents

Share this article

MANAGED VPS STARTING AT

$19 95 / mo

NEW INTEL XEON BASED SERVERS

$80 / mo

CDN STARTING AT

$0 / mo

 

By using this website you consent to the use of cookies in accordance with our privacy and cookie policy.