PHP Performance & Optimization
Most PHP performance problems aren't solved by clever micro-tricks in your code — they're solved by a handful of infrastructure and architecture decisions that apply broadly across an application. This page covers the ones that consistently matter: making sure PHP itself isn't wastefully re-parsing your code on every request, avoiding a specific and very common database access pattern that quietly multiplies query counts, caching work that doesn't need to be redone, and measuring before guessing at what's actually slow.
Enable OPcache — the single biggest win
Every time a PHP script runs, the engine has to parse the source file and compile it into an intermediate form called opcodes before it can execute anything. Without OPcache, that parsing and compiling happens again on every single request, even though the source file hasn't changed since the last request. OPcache stores the compiled opcodes in shared memory after the first compilation and reuses them on subsequent requests, skipping the parse-and-compile step entirely. For a typical web application this is routinely the single most impactful performance change available, often cutting response times dramatically, and it requires no code changes at all.
Recommended OPcache settings in php.ini
opcache.enable=1 opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 opcache.revalidate_freq=0
opcache.validate_timestamps=0 tells OPcache to trust that cached files are still current instead of checking file modification times on every request, which is faster but means a deployed code change won't take effect until OPcache is explicitly reset (most deployment pipelines do this automatically by restarting PHP-FPM or calling opcache_reset()). In local development you generally want validate_timestamps left on, so edited files are picked up immediately.
Avoid N+1 query patterns
The N+1 problem happens when code runs one query to fetch a list of records, then loops over that list running a second query per record to fetch related data. A page listing 50 orders that then queries the customer table separately for each order runs 51 queries where one or two would do. Each query carries its own round-trip latency to the database, so this pattern scales badly — the more rows in the original list, the more queries pile up, often without anyone noticing until the table grows large in production.
N+1 query vs. a single joined/batched query
<?php
// N+1: one query for orders, then one more per order
$orders = $db->query('SELECT id, customer_id, total FROM orders')->fetchAll();
foreach ($orders as $order) {
$customer = $db->query(
'SELECT name FROM customers WHERE id = ' . $order['customer_id']
)->fetch();
echo $customer['name'] . ': ' . $order['total'] . PHP_EOL;
}
// Fixed: one query total, joining the related data up front
$rows = $db->query('
SELECT orders.total, customers.name
FROM orders
JOIN customers ON customers.id = orders.customer_id
')->fetchAll();
foreach ($rows as $row) {
echo $row['name'] . ': ' . $row['total'] . PHP_EOL;
}When a join isn't practical, batching the follow-up query — fetching all the related customer rows in a single query with a WHERE id IN (...) clause and matching them up in PHP — gets the same benefit without needing a join. Most ORMs have an explicit feature for this (often called eager loading) precisely because the N+1 pattern is so easy to write by accident.
Cache expensive computations
If a piece of work is expensive and its result doesn't change on every request — a slow aggregate query, a computed report, a call to a third-party API — recomputing it from scratch every time wastes cycles that a cache could avoid entirely. APCu is a simple in-process cache built into PHP, well suited to per-server caching with no extra infrastructure. Redis and Memcached are external, shared caches that work across multiple web servers, which matters once an application is no longer running on a single machine.
Caching an expensive lookup with APCu
<?php
function getPopularProducts(): array
{
$cacheKey = 'popular_products';
$cached = apcu_fetch($cacheKey, $success);
if ($success) {
return $cached;
}
$products = expensiveDatabaseAggregation(); // slow query
apcu_store($cacheKey, $products, 300); // cache for 5 minutes
return $products;
}The key decision with any cache is picking a sensible expiry: too short and you barely reduce load, too long and users see stale data. For data that changes on a known event (a product being updated, a price changing), invalidating the cache explicitly at that moment is usually more reliable than guessing at a time-based expiry.
Profile before optimizing
Guessing at what's slow is unreliable — the part of a request that feels slow to read in code is often not where the actual time goes. A profiler records exactly how much time and memory each function call consumes during a real request, which turns "I think this loop is slow" into "this specific database call accounts for 80% of the request time." Xdebug's built-in profiler and the dedicated tool Blackfire are both commonly used for this in PHP; either one turns optimization from a guessing game into a targeted fix.
Approach | What it tells you |
|---|---|
Xdebug profiler | Generates a cachegrind file showing time spent per function call, viewable in tools like QCachegrind or KCachegrind. |
Blackfire | A dedicated profiling service with call graphs, timeline comparisons between runs, and CI integration for catching regressions. |
Database slow query log | Flags queries exceeding a time threshold directly at the database level, independent of PHP. |
PHP 8's JIT — and when it actually helps
PHP 8 introduced a Just-In-Time compiler, which can translate hot code paths into machine code at runtime instead of interpreting opcodes every time. It sounds like a universal speed boost, but its actual benefit is concentrated in CPU-bound work: tight numeric loops, image processing, encoding/decoding, machine learning-style computation — code that spends its time doing arithmetic and logic inside the PHP engine itself.
Enable OPcache in every environment; it is close to a free performance win with no code changes.
Watch for N+1 query patterns whenever code loops over a result set and queries again inside the loop.
Cache expensive, repeatable work with APCu for a single server or Redis/Memcached across multiple servers, with a deliberate expiry or invalidation strategy.
Profile with Xdebug or Blackfire before changing code for performance reasons — measure first, then fix the part that actually costs time.
Reserve expectations for the JIT to CPU-bound work; it will not meaningfully speed up a typical I/O-bound web request.