The problem
A multi-role HR and staff management platform — PHP 8.2, MariaDB, Apache on a VPS — with companion iOS and Android apps hitting a shared REST API. The server sat at 95%+ CPU under normal load. Requests slowed, and the obvious conclusion ("we've outgrown the box") would have meant paying for a bigger server every few months.
It wasn't a capacity problem. It was a configuration problem.
What I found
Three things were compounding each other:
1. OPcache was not doing its job — PHP was recompiling scripts on essentially every request, burning CPU to produce bytecode it had already produced a moment earlier.
2. PHP-FPM's process manager was tuned wrongly for the RAM actually available, so the box thrashed instead of serving.
3. MariaDB's buffer pool was too small for the working set, pushing reads that should have been in memory out to disk — and disk reads under load are what turn a slow app into an unresponsive one.
How to check this on your own server
You can confirm all three yourself in about ten minutes. Nothing here needs a consultant.
Is OPcache actually caching? Call opcache_get_status() and look at two fields. If opcache_enabled is false, that alone is your CPU. If it is true but misses keeps climbing and used_memory is at the ceiling, the cache is too small to hold your codebase and it is evicting scripts as fast as it compiles them — which is nearly as bad as having it off.
Is PHP-FPM sized for your RAM? Enable the FPM status page. If listen queue is regularly above zero, requests are waiting for a worker. Then check max children reached: if that counter is non-zero, PHP-FPM has hit its ceiling and is queueing. Raising it blindly is a trap though — see below.
Is the database reading from disk? On MariaDB or MySQL, run SHOW ENGINE INNODB STATUS and read the BUFFER POOL AND MEMORY section. Compare the buffer pool hit rate: anything below roughly 99% means real queries are going to disk. On a spinning or throttled cloud disk under load, that is the whole ball game.
How the tuning actually works
The numbers are specific to each box, but the reasoning is not. This is the method, and it is the part most "just increase max_children" advice gets wrong.
OPcache: size opcache.memory_consumption to comfortably exceed the total size of your PHP codebase, and set opcache.max_accelerated_files above your actual file count (check it — the default is often far too low for a real application). In production, opcache.validate_timestamps can be turned off so PHP stops stat-ing every file on every request, but only if your deploy process clears the cache, otherwise you will ship code that never takes effect.
PHP-FPM: pm.max_children is not a number you pick, it is a number you derive. Measure the real average memory of one PHP-FPM worker under load, then divide the RAM you can actually spare by that figure. Setting it higher than that does not give you more throughput — it gives you swapping, and a swapping server is slower than a queueing one. This is why raising max_children often makes things worse.
InnoDB buffer pool: innodb_buffer_pool_size should be large enough to hold the hot working set. On a dedicated database server the usual guidance is around 70% of RAM; on a box that also runs PHP and Apache — as this one did — you must leave headroom for the workers you just sized above, or the two compete and both lose.
Swap: swap is not a performance feature and it will not make a server faster. It is a safety margin, so that a spike degrades into slowness instead of the OOM killer terminating your processes. Adding 4 GB here was insurance, not a fix.
What I changed
Enabled and sized OPcache so the codebase stays compiled in memory. Retuned PHP-FPM's process manager against real measured memory headroom rather than defaults. Configured the MariaDB buffer pool to hold the working set while leaving room for the PHP workers on the same box. Added 4 GB of swap as a safety margin.
Then the database work: profiling the slow query log, and fixing the indexes behind the queries it surfaced. That is where the 8× query speed improvement came from — not from new hardware.
The result
Server CPU came down from 95%+ sustained to stable under full load. Query performance improved 8×. No rewrite, no migration, no bigger instance — the same hardware, configured correctly.
The lesson
When a PHP server pins its CPU, the instinct is to scale up. Usually the box is fine and the configuration is not. OPcache, PHP-FPM sizing and the database buffer pool are the first three places to look — before anyone signs off on a bigger instance or a rewrite.