7 Things About PHP Performance That Most Developers Get Wrong
array_merge in a loop is 269× slower. Objects beat arrays. foreach beats for. Seven PHP performance beliefs that need updating.
7 Things About PHP Performance That Most Developers Get Wrong
array_merge in a loop is 269× slower. Objects beat arrays. foreach beats for. Seven PHP performance beliefs that need updating.
Photo by Lyle Hastie on Unsplash
A junior dev joins the team and asks how to optimize the slow checkout page. The senior dev pairs up with them, opens the file, and starts pointing things out. “Switch these double quotes to single quotes — they’re faster. Cache that count() call. Replace print with echo. And these foreach loops are slow, rewrite them as for loops."
Three days later, the page is 0.2% faster. The actual bottleneck — a query running 47 times per page render — is sitting exactly where it was, generating exactly the same 800ms response time. The PHP execution time inside that 800ms is maybe 4 milliseconds. Of which they shaved 8 microseconds.
This is the most common shape of PHP performance work in the wild: hours of effort optimizing things that don’t matter while the things that do matter get ignored. Most of the advice being passed around stopped being true years ago. The runtime kept improving; the folk wisdom didn’t keep up.
What follows is seven beliefs about PHP performance that get repeated in code reviews, blog posts, and StackOverflow answers, and that the actual benchmarks contradict. Every number was measured on PHP 8.3.6. Some of these used to be true and aren’t anymore. Some were never true, just plausible enough that nobody bothered to check. The pattern across all of them is that PHP performance instinct needs to be re-examined every few years.
TL;DR Speedrun
- Single vs double quotes: the difference is 10 nanoseconds per string. It rounds to zero in any real workload.
foreachis faster than the equivalentforloop in modern PHP — roughly 30-50% faster, not slower.array_mergeinside a loop is genuinely catastrophic. 269x slower than a direct[]=push. This is the one micro-optimization belief that's actually correct, and underrated.array_map,array_filter, and friends are slower thanforeachbecause of closure-call overhead per element. The functional style reads nicer; it's not free.- Object property access is faster than associative array key access in PHP 8+ — by about 6x with typed properties. The “objects are slow” intuition is a holdover from PHP 5.
- OPcache is the single biggest performance lever in PHP, and a depressing fraction of production deployments still have it misconfigured.
- The N+1 query problem dwarfs every micro-optimization combined. A 100-query page with sloppy PHP runs slower than a 2-query page with unoptimized PHP.
What You’ll Learn
- The actual numbers behind seven common PHP performance beliefs, with reproducible benchmarks
- The one micro-optimization that’s underrated (and the cluster of beliefs around it that are wildly overrated)
- Why
foreachbeatsfor, why typed objects beat arrays, and what changed in PHP 7→8 to flip these - How
array_mergein a loop becomes O(n²) and what to use instead - The OPcache configuration most teams get wrong, even when they think they have it right
- Why profiling first beats guessing, every single time
Belief 1: Single Quotes Are Faster Than Double Quotes
This is the most-repeated piece of PHP performance advice in existence. It even has a plausible-sounding mechanism: PHP scans double-quoted strings for $variable references, single-quoted strings get used verbatim, ergo single quotes are faster.
The mechanism is real. The conclusion is so wrong it’s almost funny.
$iterations = 1_000_000;
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$s = 'simple string';
}
$single = microtime(true) - $start;
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$s = "simple string";
}
$double = microtime(true) - $start;
Single quotes: 3.37ms
Double quotes: 3.38ms
Difference: 0.01ms over 1,000,000 iterations
Per call: 0.00000001 seconds (10 nanoseconds)
A request that constructs a thousand strings would save 10 microseconds total. Out of a request that takes hundreds of milliseconds. To reach a perceptible difference, the application would need to construct hundreds of millions of strings per request — at which point the script has bigger problems than quote style.
The right reason to choose between them is readability. "Hello, {$name}" reads better with interpolation than 'Hello, ' . $name. 'static string' reads fine without it. Make the choice based on what the code says, not on a microsecond budget that doesn't exist.
Belief 2: foreach Is Slower Than for
The intuition makes sense if you grew up on C-style languages. for ($i = 0; $i < $n; $i++) is just integer arithmetic and pointer indexing. foreach is doing... something. Setting up an iterator. Copying values. Whatever it does, classical for should be lower-level and therefore faster.
Wrong, in PHP. PHP arrays aren’t C arrays. They’re hash tables. Looking up $arr[$i] is a hash operation, not a pointer dereference. foreach walks the underlying linked list of hash entries directly — no hash lookup per iteration.
$big = range(1, 10000);
$runs = 1000;
$start = microtime(true);
for ($r = 0; $r < $runs; $r++) {
$sum = 0;
foreach ($big as $v) {
$sum += $v;
}
}
$foreachTime = microtime(true) - $start;
$start = microtime(true);
for ($r = 0; $r < $runs; $r++) {
$sum = 0;
$c = count($big);
for ($i = 0; $i < $c; $i++) {
$sum += $big[$i];
}
}
$forTime = microtime(true) - $start;
foreach: 57ms
for: 85ms
for is 48% slower for the same work. The hash lookup on every iteration is the cost.
This isn’t true for all for loops — counting backwards, skipping every other element, or iterating a numeric range that doesn't correspond to array indices is fine. The case where foreach wins is the dominant case: walking every element of an array, in order, to do something with each. For that, foreach should be the default.
There’s one situation where for is genuinely the right tool: when the loop body modifies the array. foreach operates on a copy of the iteration state, so adding or removing elements during iteration produces different behavior than the classic for loop. For mutation, reach for for. For reads, reach for foreach and stop second-guessing it.
Belief 3: count() in a Loop Is a Performance Bug
This one is so deeply ingrained in PHP code review that linters check for it. “You’re calling count() on every iteration. Cache it."
// "Bad"
for ($i = 0; $i < count($array); $i++) { ... }
// "Good"
$c = count($array);
for ($i = 0; $i < $c; $i++) { ... }
The rationale given is almost always wrong. The myth says count() walks the array — that calling it costs O(n). It doesn't. PHP arrays store their size as a field on the internal HashTable struct. count() reads that field directly. It's been O(1) since at least PHP 5.
But the “good” version is faster, just not for the reason people think:
count() inline: 115ms
count() cached: 88ms
About 30% faster. The reason isn’t algorithmic complexity — it’s function call overhead. PHP function calls aren’t free. They push a stack frame, do parameter binding, return through the type system. Doing this ten thousand times to call a function that returns a stored integer is wasteful, regardless of what the function computes.
This generalizes. Cache any function call you’d otherwise make repeatedly with the same arguments inside a loop. strlen($str), count($arr), array_keys($arr) — they all benefit. Not because the operations are expensive, but because the calls themselves have overhead.
The corrected mental model: it’s not a count()-specific optimization. It's a "don't call any function unnecessarily" optimization. Loop conditions are just where it shows up most often.
Belief 4: array_merge in a Loop Is Fine, Just Less Elegant
This is the one belief on the list that goes the opposite way — most developers underestimate how bad array_merge in a loop actually is.
The pattern looks innocent:
$merged = [];
foreach ($chunks as $chunk) {
$merged = array_merge($merged, $chunk);
}
It reads naturally. It does what it says. And it’s a performance disaster, because every call to array_merge copies the entire accumulator. As the accumulator grows, each iteration copies more. The whole loop is O(n²).
// 1000 chunks of 10 elements each = 10,000 elements final
$chunks = [];
for ($i = 0; $i < 1000; $i++) {
$chunks[] = range($i * 10, $i * 10 + 9);
}
// Method 1: array_merge in loop
$merged = [];
foreach ($chunks as $chunk) {
$merged = array_merge($merged, $chunk); // copies $merged every time
}
// Method 2: direct push
$merged = [];
foreach ($chunks as $chunk) {
foreach ($chunk as $item) {
$merged[] = $item;
}
}
// Method 3: single splat call
$merged = array_merge(...$chunks);
array_merge in loop: 47.42ms
Direct push: 0.18ms (269× faster)
Single splat call: 0.04ms (1185× faster)
The 269× number is the real headline. This isn’t a 30% optimization. The naive code is hundreds of times slower than the fix, and it gets worse as data grows. Doubling the input doesn’t double the runtime — it quadruples it.
Worse: the bug is invisible until production. A staging dataset with 100 chunks runs the loop in maybe 0.5ms — fine. The same code in production with 5,000 chunks takes 340ms, because O(n²) doesn’t show up until n gets big. By that point the code is everywhere.
If you do nothing else with the seven items in this article, internalize this one. Every array_merge($acc, $thing) inside a loop is a future production incident waiting to happen. The fix is []= $item for individual elements or array_merge(...$listOfArrays) for combining a known set of arrays in one call. Both are linear. Neither is hard to write.
Belief 5: array_map and array_filter Are “Functional” and Therefore Fast
The functional style reads beautifully:
$doubled = array_map(fn($x) => $x * 2, $numbers);
$evens = array_filter($numbers, fn($x) => $x % 2 === 0);
Compared to the imperative version, it’s tighter, more declarative, easier to skim. There’s a temptation to assume something this clean must be optimized.
It isn’t. Each call to the closure is a full function invocation — push stack frame, bind arguments, return through the type system. For 100,000 elements that’s 100,000 function calls.
$big = range(1, 100_000);
$runs = 100;
// array_map with closure
$result = array_map(fn($x) => $x * 2, $big);
// array_map: 576ms
// array_filter with closure
$result = array_filter($big, fn($x) => $x % 2 === 0);
// array_filter: 820ms
// Equivalent foreach
$result = [];
foreach ($big as $x) {
$result[] = $x * 2;
}
// foreach: 326ms
foreach is 1.8× faster than array_map and 2.9× faster than array_filter. The difference is entirely the closure invocations.
This isn’t an argument against ever using array_map and array_filter. They're often the right choice for readability, and on small arrays the absolute difference is irrelevant. The argument is against assuming they're free, or worse, assuming they're faster because they "look optimized."
For hot paths processing large collections, the imperative foreach version wins. For everything else, write whichever reads better and stop worrying about it.
Belief 6: Objects Are Slower Than Associative Arrays
This is the most expensive of the wrong beliefs in this list, because it shapes how entire codebases are written. The reasoning sounds reasonable: arrays are PHP’s native data structure, objects have method dispatch overhead, ergo arrays must be faster for “just data.”
PHP 8 changed this. Specifically, typed properties on classes get compiled to fixed offset slots in the object’s internal storage. Accessing $obj->id is roughly a pointer dereference. Accessing $arr['id'] is a hash lookup, which has to compute a hash and walk a bucket.
class Item {
public function __construct(
public int $id,
public string $name,
) {}
}
$objArray = [];
$plainArray = [];
for ($i = 0; $i < 10_000; $i++) {
$objArray[] = new Item($i, "item{$i}");
$plainArray[] = ['id' => $i, 'name' => "item{$i}"];
}
$runs = 100;
$start = microtime(true);
for ($r = 0; $r < $runs; $r++) {
$sum = 0;
foreach ($objArray as $o) {
$sum += $o->id; // typed property access
}
}
$objTime = microtime(true) - $start;
$start = microtime(true);
for ($r = 0; $r < $runs; $r++) {
$sum = 0;
foreach ($plainArray as $a) {
$sum += $a['id']; // hash lookup
}
}
$arrTime = microtime(true) - $start;
Object property access ($o->id): 10ms
Array key access ($a['id']): 64ms
The object version is 6× faster. Typed public properties are the key — the typing lets PHP allocate fixed slots at class definition time, so access becomes pointer arithmetic. Untyped properties give up some of this advantage; readonly properties keep all of it.
This generalizes way beyond a microbenchmark. Code organized around small typed value objects (User, Order, LineItem) runs as fast as or faster than code that passes around ['id' => 1, 'email' => 'x@y'] arrays. It's also more grep-able, more refactorable, more amenable to static analysis, and produces vastly better error messages when something goes wrong. The "use arrays for performance" advice from the PHP 5 era is exactly inverted in modern PHP.
The pragmatic upshot: if a piece of data has a known shape, give it a class. The performance argument for arrays is gone. The maintainability argument for classes is louder than ever.
Belief 7: OPcache Doesn’t Need Tuning, the Defaults Are Fine
OPcache is enabled by default in production PHP installations. So most teams treat it as a checked box and move on. They’re leaving 30–50% of throughput on the table.
The default configuration is conservative. opcache.memory_consumption=128 (128MB) is fine for small applications and starves large ones — running over the limit causes OPcache to flush and restart caching from scratch repeatedly, which is worse than no cache at all because the application pays the parsing cost plus the cache management overhead.
opcache.max_accelerated_files=10000 sounds like a lot until you realize a typical Symfony or Laravel app has 15,000-30,000 PHP files. Files past the limit don't get cached. Every request re-parses them.
opcache.validate_timestamps=1 is the right default for development but the wrong default for production. With it on, PHP stat()s every file on every request to check if it changed. With it off, PHP trusts the cache and skips the stat — saving thousands of syscalls per request, but requiring opcache_reset() or PHP-FPM restart on deploy.
The production-grade configuration looks more like:
; ~/php.ini for production
opcache.enable=1
opcache.memory_consumption=512 ; or higher for large apps
opcache.max_accelerated_files=50000 ; leave headroom over actual file count
opcache.validate_timestamps=0 ; trust the cache, reset on deploy
opcache.interned_strings_buffer=32 ; bigger string cache
opcache.preload=/var/www/preload.php ; PHP 7.4+, preload framework classes
For development, the same memory settings but validate_timestamps=1 with revalidate_freq=0 — checks every request, recompiles on change, gives the speedup without sacrificing the dev workflow.
The diagnostic that catches this misconfiguration: opcache_get_status()['opcache_statistics']['oom_restarts']. Any value above zero means the cache filled up and got reset. Each reset is a stampede where every request re-parses every file. If oom_restarts is climbing in production, the memory limit is wrong.
The 5 minutes to tune php.ini properly delivers more performance improvement than a month of code-level micro-optimization. It's also the most boring fix possible, which is why it gets skipped.
Pitfalls to Avoid
Optimizing without measuring. Profile first. Xdebug (xdebug.mode=profile), Blackfire, or Tideways all show where time actually goes. The hot path is almost never where you assumed. Optimizing the wrong place burns hours and ships zero improvement.
Trusting performance advice without a date. PHP changed dramatically between 5, 7, and 8. Advice from before each major version may have been right then and wrong now. If a blog post about PHP performance doesn’t mention a version, run the benchmark before believing the claim.
Confusing readability with performance. The “single quotes are faster” debate isn’t a performance question, it’s a style preference dressed up as one. The right axis for syntactic choices that are within rounding error of each other is readability, not microseconds.
Optimizing the request handler while the database is the bottleneck. A common pattern: a team profiles slow PHP, finds nothing dramatic, and starts micro-optimizing the controller. Meanwhile the slow query log is full of 800ms queries. Look at the slow query log first. If queries are slow, no PHP optimization will save you.
Assuming array_merge(...$arrays) is the same as a loop. It isn't. The splat-call version runs in linear time because PHP can size the result array correctly upfront. The loop version is O(n²) because each call has to grow and copy the accumulator. Same function, different complexity.
Premature object pooling. Some teams reach for object pools or factory caches because they read that “object instantiation is expensive.” It isn’t. Creating an object in PHP 8 is roughly the cost of allocating a hash table — measured in microseconds, dwarfed by anything the object actually does. Pool when profiling shows allocation is the bottleneck. Don’t pool prophylactically.
Mini Q&A
Will these benchmarks hold on PHP 8.4 / 8.5 / future versions?
The directional results are stable: foreach faster than for, typed objects faster than arrays, micro-optimizations dwarfed by macro ones, array_merge in a loop is O(n²). The exact numbers shift across minor versions as the engine gets optimized. When a new PHP version drops, re-run the benchmarks that matter for your codebase rather than trusting a snapshot from one version.
Is JIT in PHP 8 worth turning on?
For typical web applications, no — the bottleneck is the database and I/O, not PHP execution time. JIT helps for compute-heavy code (cryptography, image processing, mathematical simulation) where it can deliver 2× or better speedups. Profile first; if CPU isn’t the bottleneck, JIT won’t help. Most web applications have CPU as a tiny fraction of total time.
What about ReactPHP, Swoole, RoadRunner?
Different shape of optimization. They solve “PHP-FPM has process startup overhead per request” by running PHP as a long-lived process that handles many requests. The wins are real (sometimes 10× throughput for specific workloads) but they require code changes — anything that assumes “globals reset between requests” breaks. Worth considering for high-traffic APIs; usually not the right first move.
How do I know what to actually profile?
Start with the slow request. Use New Relic, Datadog APM, or the equivalent to find which endpoint has the worst P95 latency. Profile that one. The output usually shows two or three functions accounting for most of the time. Optimize those. Repeat. Most performance wins in real applications are this boring; there’s rarely a single magic fix.
Wrap-Up
PHP performance folklore is overdue for a refresh. Most of the advice that gets repeated stopped being true around the time PHP 7 shipped, and the runtime has kept improving while the lore stayed frozen. Single quotes don’t matter. foreach beats for. Objects beat arrays. The micro-optimizations everyone agrees on are dominated by changes nobody talks about — OPcache configuration, query design, the memory layout of typed properties.
The optimizations that actually move the needle are macro: OPcache tuned correctly, queries that don’t N+1, indexes that the optimizer actually uses, FPM workers sized for peak traffic, caching that hits when it should. Each of these is bigger than every micro-optimization in this article combined. The teams shipping fast PHP work on these. The teams arguing about quote types in code review usually don’t.
The one corrective discipline worth more than any specific tip is profiling before optimizing. Almost every assumption about where PHP time goes gets revised the first time someone actually measures. The benchmarks above weren’t done because micro-optimization is fascinating; they were done because the alternative is folklore that hasn’t been true for a decade.
Closing Loop
Imagine the same junior dev from the opening, two months later. The slow page is still slow. This time the team does something different — they run a profiler.
The profile shows the bottleneck immediately: OrderRepository::findByCustomer accounts for 92% of the request time, called 47 times per render. It's the N+1 nobody saw because they were busy reviewing quote styles. Someone adds ->with('orders') to the eager load. The page goes from 800ms to 60ms. That's a 13× improvement from a 12-character change.
The next time something is slow, the team skips the quote-type stage entirely and goes straight to the profiler. The hot path is always something specific — a missing index, a JSON encoding step running twice, a cache that doesn’t hit because the key includes a timestamp. None of them appear on any “PHP performance tips” list. None of them are micro-optimizations.
The performance work that matters is mostly invisible to lists of tips. It’s a series of small specific findings, each unique to the codebase, each invisible until the profiler runs. The lists are entertainment. The profiler is the tool.
“People Also Ask”
1. Are single quotes really faster than double quotes in PHP? Technically yes, by about 10 nanoseconds per string. In practice, no — the difference is below measurement noise on any real workload. Pick quote style based on readability: double quotes when interpolation is useful, single quotes for literal strings. Performance shouldn’t enter the conversation.
2. Is foreach slower than for in PHP? The opposite. Verified on PHP 8.3, foreach is 30-50% faster than the equivalent classical for loop when iterating an array. PHP arrays are hash tables, so $arr[$i] is a hash lookup on every iteration; foreach walks the internal structure directly without lookups. The "for is faster" intuition is correct in C-style languages and wrong in PHP.
3. Why is array_merge in a loop bad? Each call to array_merge copies its arguments into a new array. Inside a loop where one argument is an accumulator that grows, every iteration copies a larger accumulator — making the whole loop O(n²) instead of O(n). Verified on 1,000 chunks: 47ms with array_merge in a loop vs 0.18ms with direct []= $item push (269× faster). The fix is either []= for individual elements or a single array_merge(...$arrays) call to combine a known list.
4. Should I cache count() before a loop? Yes, but not for the reason usually given. count() is O(1) on PHP arrays — it reads a stored size, doesn't walk anything. The reason caching helps is function-call overhead: calling any function 10,000 times costs more than calling it once. The same logic applies to strlen(), array_keys(), or any function called repeatedly with the same arguments inside a loop.
5. Are array_map and array_filter slower than foreach? Yes, because every element triggers a closure invocation with full function-call overhead. array_map is roughly 1.8× slower than foreach for the same operation; array_filter is roughly 2.9× slower. The difference matters on hot paths with large arrays, doesn't matter on small arrays. Use whichever reads better unless a profiler says otherwise.
6. Are objects really faster than associative arrays in PHP 8? Yes, surprisingly. Object property access via $obj->id (especially with typed properties) compiles to fixed-offset access — roughly a pointer dereference. Associative array access via $arr['id'] is a hash lookup that has to compute a hash and walk a bucket. Verified on 10,000 elements: 10ms object iteration vs 64ms array iteration (6× faster). The "objects are slow" advice is a PHP 5 holdover that's been wrong since PHP 7.4.
7. What’s the most important PHP performance setting most teams get wrong? OPcache memory configuration. The default memory_consumption=128 and max_accelerated_files=10000 are too low for medium-to-large applications. When OPcache fills up, it flushes and restarts caching from scratch, which is worse than no cache. Check opcache_get_status()['opcache_statistics']['oom_restarts'] — any non-zero value means the limits need to go up.
8. How do I profile a PHP application? Xdebug profiling (xdebug.mode=profile) generates Cachegrind files for development analysis. Blackfire and Tideways have production-safe sampling modes that profile a small fraction of requests without significant overhead. New Relic and Datadog APM provide the same data integrated with broader monitoring. For quick local checks, bracketing suspected slow code with microtime(true) calls reveals enough to start. The order matters: profile first, optimize second.
Note: All benchmarks were run on PHP 8.3.6 with default configuration. Micro-benchmark numbers are sensitive to CPU caching, system load, and PHP version — results within ±20% of these figures should be expected on different hardware. The directional findings (foreach beats for, objects beat arrays, array_merge in a loop is O(n²), micro-optimizations are dwarfed by macro ones) are stable across PHP 8.x versions. For benchmarks that affect production decisions, run them on hardware similar to production and against your actual workload, not synthetic loops.
메타데이터
- post_id
- 432cbee0eceb
- slug
- 7-things-about-php-performance-that-most-developers-get-wrong-432cbee0eceb
- url
- https://medium.com/@annxsa/7-things-about-php-performance-that-most-developers-get-wrong-432cbee0eceb
- canonical_url
- https://medium.com/@annxsa/7-things-about-php-performance-that-most-developers-get-wrong-432cbee0eceb
- author_url
- https://medium.com/@annxsa
- status
- ok
- fetched_at
- 2026-06-09 15:37:30