Most slow PHP applications are not slow because of PHP. They are slow because of database queries — usually the same query running hundreds of times inside a loop. This guide covers how to find out where the time actually goes, and then the fixes in the order that pays best: measure, fix the queries, enable OPcache, add caching, and only then micro-optimise.
Measure before you change anything#
<?php
$start = hrtime(true);
// the code you suspect
$ms = (hrtime(true) - $start) / 1_000_000;
error_log(sprintf("Block took %.2f ms", $ms));
A rough breakdown of a page tells you where to look:
<?php
final class Timer
{
private array $marks = [];
private float $start;
public function __construct() { $this->start = hrtime(true); }
public function mark(string $label): void
{
$this->marks[$label] = (hrtime(true) - $this->start) / 1_000_000;
}
public function report(): string
{
$out = "";
$previous = 0.0;
foreach ($this->marks as $label => $at) {
$out .= sprintf("%-28s %8.2f ms\n", $label, $at - $previous);
$previous = $at;
}
return $out;
}
}
For anything serious, install a profiler. Xdebug’s profiler or Blackfire will show you the actual call tree and where the time goes, which is far more reliable than guessing.
<?php
// How many queries ran, and how long they took
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [LoggingStatement::class]);
The N+1 query problem#
This is the most common cause of a slow PHP page, by a wide margin:
<?php
// 1 query for the posts
$posts = $pdo->query("SELECT id, title, author_id FROM posts LIMIT 50")->fetchAll();
foreach ($posts as $post) {
// ...then 50 more, one per post
$stmt = $pdo->prepare("SELECT name FROM authors WHERE id = ?");
$stmt->execute([$post["author_id"]]);
$post["author"] = $stmt->fetchColumn();
}
Fifty-one queries. Each is fast; the round trips are what cost you. Two ways to fix it:
<?php
// Option 1: a join
$sql = "SELECT p.id, p.title, a.name AS author
FROM posts p
JOIN authors a ON a.id = p.author_id
LIMIT 50";
$posts = $pdo->query($sql)->fetchAll();
<?php
// Option 2: two queries, joined in PHP
$posts = $pdo->query("SELECT id, title, author_id FROM posts LIMIT 50")->fetchAll();
$ids = array_unique(array_column($posts, "author_id"));
$placeholders = implode(",", array_fill(0, count($ids), "?"));
$stmt = $pdo->prepare("SELECT id, name FROM authors WHERE id IN ($placeholders)");
$stmt->execute($ids);
$authors = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($posts as &$post) {
$post["author"] = $authors[$post["author_id"]] ?? "Unknown";
}
unset($post);
Fifty-one queries become one or two. On a page that was taking two seconds, this alone often brings it under two hundred milliseconds.
Indexes#
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
If the output says type: ALL, the database is reading every row. Add an index on the columns you filter and sort by:
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
Column order in a composite index matters: it can serve queries filtering on customer_id alone, or on both, but not on status alone. Put the most selective column first.
Also: select the columns you need rather than SELECT *. Pulling a large text column you never display costs bandwidth and memory on every row.
OPcache#
Without OPcache, PHP recompiles every file on every request. With it, compiled bytecode is kept in memory. This is typically the largest single improvement available, and it is a configuration change rather than a code change.
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.jit_buffer_size=64M
opcache.jit=tracing
<?php
print_r(opcache_get_status()["opcache_statistics"]);
Watch two numbers: the hit rate should be well above 95 per cent, and oom_restarts should be zero. Restarts mean the memory setting is too small.
Caching#
The fastest work is work you do not do. Cache in layers, cheapest first:
<?php
// 1. Within a single request
final class Repository
{
private array $cache = [];
public function find(int $id): ?array
{
return $this->cache[$id] ??= $this->fetchFromDatabase($id);
}
}
<?php
// 2. Across requests, with Redis or Memcached
$key = "products:featured";
$json = $redis->get($key);
if ($json === false) {
$products = $pdo->query("SELECT ...")->fetchAll();
$redis->setex($key, 300, json_encode($products));
} else {
$products = json_decode($json, true);
}
<?php
// 3. HTTP caching, so the browser does not ask again
header("Cache-Control: public, max-age=300");
header("ETag: " . md5($body));
if (($_SERVER["HTTP_IF_NONE_MATCH"] ?? "") === md5($body)) {
http_response_code(304);
exit;
}
The hard part of caching is invalidation. Prefer short expiry times to clever invalidation logic until you have evidence you need more.
Loops and memory#
<?php
// Loads the whole file into memory
$lines = file("huge.csv");
// Reads one line at a time
$handle = fopen("huge.csv", "r");
while (($row = fgetcsv($handle)) !== false) {
process($row);
}
fclose($handle);
A generator gives you the same memory profile with a nicer shape:
<?php
function readRows(string $path): Generator
{
$handle = fopen($path, "r");
try {
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
} finally {
fclose($handle);
}
}
foreach (readRows("huge.csv") as $row) {
process($row);
}
Also worth knowing: calling count() inside a loop condition re-counts every iteration. Assign it once beforehand.
Things that matter less than people think#
- Single versus double quotes. The difference is unmeasurable in real applications.
echoversusprint. Same.- Removing all comments. Comments are stripped at compile time and cached by OPcache.
- Micro-tuning a function called ten times. Optimise what runs ten thousand times.
These come up constantly in older performance advice. Time spent on them is time not spent on the queries.
Infrastructure#
- Run a current PHP version. PHP 8 is several times faster than PHP 5, and each release adds more.
- Use PHP-FPM with a process count matched to your memory.
- Enable gzip or brotli at the web server.
- Serve static files from the web server, never through PHP.
- Put a CDN in front of images, CSS and JavaScript.
composer dump-autoload --optimize --classmap-authoritative
That last command converts Composer’s autoloader into a flat map, removing a filesystem check on every class load.
Questions people ask#
Is PHP slow?
Modern PHP is fast enough that the database and network dominate almost every real application. Performance complaints about PHP usually date from version 5.
Should I turn on JIT?
It helps CPU-heavy work such as image processing and mathematics. For typical web requests, which are dominated by I/O, the gain is small. Measure with it on and off.
Redis or Memcached?
Redis, in most cases — it supports more data types, can persist to disk, and does everything Memcached does. Memcached is marginally simpler if you only need string caching.
How do I know if a page is slow enough to matter?
Look at real request timings in your server logs rather than at your local machine. A page that takes 300 ms locally may take two seconds under production load with a cold cache.
Where to go next#
- PHP and databases — queries, indexes and PDO.
- PHP security best practices — prepared statements, which are also faster when reused.
- PHP deployment and hosting — where the OPcache settings go.