PHP 8.6 Rewrites Some of Your Calls Before You Notice
Most PHP speed-ups ask something of you: upgrade the extension, turn on the JIT, rewrite the hot loop in C. The PHP 8.6 changes in the NEWS file ask nothing. The compiler looks at a few specific call shapes and emits something else in their place. Your source stays the same, and the bytecode changes underneath it.
This article covers all the rewrites, and, in detail, the two that matter most. I checked them by dumping the optimized opcodes of PHP 8.6 itself, then measured the code against PHP 8.5 on the same machine. The 8.6 build is a development snapshot, 8.6.0-dev, at the time of writing.
Where the time goes
A PHP function call is not free, but it is not the expensive part here. Three costs matter for the changes in PHP 8.6.
Parsing the format string. printf() and sprintf() read the format on every call, walk it character by character, and dispatch each % specifier. The work is small, tiny even, but it happens every time, even when the format never changes. These are not cached.
Creating a Closure. A first-class callable such as double(...) or a closure such as fn($x) =>; double($x) produces a Closure object. That is an allocation, paid once per expression evaluation, not once per written line of code. In a loop, closures multiply.
Calling back into userland from C. When array_map() runs a userland callback, C code calls back into the Zend VM. Each call goes through zend_call_function(), which builds a frame from the outside and re-enters the executor. A foreach loop in userland never leaves the VM: the callee is an ordinary opcode in the same loop.
The 8.6 rewrites attack these costs in different ways. printf removes the format parse. array_map() removes the callback machinery, and with it the need for a Closure object. Let’s see them in action, before they pass.
printf becomes string interpolation
When printf() receives only %s and %d, the compiler replaces it with string interpolation. Here is the source:
$count = 3;printf(‘%s has %d messages’, $name, $count);
[hp]
PHP 8.6 compiles that call into the same opcodes as this:
$count = 3;echo “$name has ” . (int) $count . ” messages”;
[hp]
The optimizer dump for the sprintf() version confirms it. The four opcodes that build the string are the same ones interpolation uses:
T3 = ROPE_INIT 3 CV0($a)
T3 = ROPE_ADD 1 T3 string(" ")
T5 = CAST (long) CV1($b)
T2 = ROPE_END 2 T3 T5
[hp]
Two things make the rewrite correct.
The first is that %s and interpolation agree. Both convert the value with the same rules, including calling __toString() on objects.
The second is that %d is not interpolation. %d converts its argument to an integer, so the compiler must insert the (int) cast, which is the CAST (long) line above. Without it, the output changes:
echo “$x”, “\n”; // 1.9
[hp]
That is why the rewrite is limited to %s and %d. Other specifiers, or more complex configurations of these specifiers, such as %05.2f or %'*10s, carry padding and precision rules that a string concatenation cannot express without a second function call.
Note that this optimisation is actually already available in PHP 8.4 and 8.5, via sprintf(). PHP 8.6 is porting it to printf(). You can detect simple printf() calls by parsing the format string, and rewrite it to concatenation when it is simple to handle.
Besides the fact that this is a micro-optimisation, it may also run against the established rules of always using printf() for concatenation, and it makes any modification, such as adding extra configuration, another rewrite. So, unless you need to speed up the application by a few milliseconds, it is recommended to upgrade PHP to 8.6, and let the engine do it for you.
array_map loses its callback
The second rewrite is the one I find more interesting. array_map() with a first-class callable or a partial function application is compiled into a foreach:
$doubled = array_map(double(…), $numbers);
[hp]
The optimizer dump for that call shows a loop, not a function call. The function body is a foreach over the array, with a direct call to double inside it:
V2 = FE_RESET_R CV0($arr) 0010
T4 = FE_FETCH_R V2 T3 0010
INIT_FCALL 1 112 string("double")
SEND_VAL T3 1
T3 = DO_UCALL
FE_FREE V2
[hp]
The rewrite also covers internal callables. array_map(intval(...), $ids) becomes a loop that calls intval directly, with ADD_ARRAY_ELEMENT building the result.
The compiled form keeps array_map() semantics. With a single input array, array_map() preserves keys, including string keys. The loop does the same, because ADD_ARRAY_ELEMENT takes the key from the source iteration:
// [b] =>; Y
[hp]
The rewrite does not apply to a closure written with fn. The dump for array_map(fn($x) =>; double($x), $arr) still shows a call to array_map itself (DO_ICALL). That matches the NEWS entry, which names only first-class callables and partial application.
The benefit is what the JIT can see. Inside the rewritten function there is a plain loop with a visible call, instead of an opaque internal function that calls back. The NEWS entry puts it as better insight for the JIT.
This will address an ongoing pain point, where inlining the content of the closure into a local foreach makes the code faster than using the closure, or an established method: in particular, the context switching, which happens at each cycle of the loop adds a lot of overhead. With this optimisation, the inlining is done at the engine level.
Given how much loops hold as speed potential, this might be a visible speed up.
The smaller changes
The PHP 8.6 NEWS file lists more speed-ups, which I group into the following sections. None of them rewrites your code the way printf() and array_map() do. Each one is a faster path inside a function you already call, so the code stays the same and only the cost changes.
I have measured these with a simple loop, repeating the same operation. The NEWS entries say that each path is faster, and the path may be a special one. Each section below shows the code the change targets, and I ran every snippet on PHP 8.6 to confirm the output.
addcslashes(): octal escapes
addcslashes() escapes the characters you list. Control characters that have no short form, such as \x01, come out as three-digit octal escapes.
echo addcslashes(“tab\there\x01end”, “\0..\37”), “\n”;
// tab\there\001end
[hp]
The \t stays a short escape, and the \x01 becomes \001. The octal branch is the one that got faster, so the change matters most when you escape binary data, for example before writing a string into a generated source file. You might also need a large loop to feel the benefits.
sort(): single-element arrays
Sorting one element needs no comparisons. Whatever time sort() spends on such an array is setup, not sorting.
sort($tags);
echo json_encode($tags), “\n”; // [“php”] [hp]
The result is the same as before, so you only see the gain when you sort many small arrays in a loop, such as one array per database row. A long list of one-element arrays is certainly a coding pattern, applied to data whose shape is defined somewhere else. An actual micro-optimisation.
arrayfillkeys(): building a lookup from a list
array_fill_keys() takes a list of keys and one value, and returns an array where each key maps to that value. It is the usual way to build a set of flags or defaults in one line.
echo json_encode($flags), “\n”;
// {“read”:false,”write”:false,”admin”:false}
[hp]
The NEWS entry says the function is faster, without saying which part of the work changed. hrtime() could not spot significant differences in execution, with many calls, and with many keys.
Note: also remember that the SAME value is assigned to each provided key. This is inconsequential with a scalar, but it means the same object is everywhere, when the value is an instantiation.
array_intersect(): keeping the first array’s keys
array_intersect() returns the values of the first array that also appear in every other array. The keys of the first array are kept, and values are compared as strings.
$paid = [2, 4, 6];
echo json_encode(array_intersect($all, $paid)), “\n”;
// {“1″:2,”3”:4}
[hp]
The output keeps the original keys, 1 and 3. Code that expects a list has to call array_values() afterwards, which the speed-up does not change. The entry names this function without giving a case, so the gain could apply to any input size.
The speed gain is 90% on small arrays as in the illustration. They still need to be executed 100 000 times, so it is a micro optimisation, but the impact is significant, and might become visible.
array_map() with several arrays
With one array, array_map() calls the callback once per element. With several arrays, it takes one element from each on every step, and pads the shorter arrays with null. The NEWS entry says this case is faster.
This is a separate entry from the first-class callable rewrite described earlier. The NEWS file does not say whether array_map(double(...), $a, $b) is also rewritten into a loop. No significant speed up was measured.
arraysum() and arrayproduct() on integers
array_sum() and array_product() add or multiply every element. The NEWS entry limits the gain to arrays that contain only integers. That is a natural place for a faster path, because the function does not need to check each element’s type or convert it.
echo array_product([2, 3, 4]), “\n”; // 24// Overflow still promotes to float, so the fast path has to keep this:
var_dump(array_sum([PHP_INT_MAX, 1])); // float(9.223372036854776E+18)
[hp]
The last line is the part a fast integer path must not break. When the total no longer fits in an integer, the result has to become a float rather than wrap around. The output above is what PHP 8.6 returns.
arrayunshift() and arraywalk()
array_unshift() puts new values at the front of an array and renumbers the integer keys. array_walk() calls a callback for each element, and the callback can change the element by reference. The NEWS entry names both functions.
echo json_encode($prices), “\n”; // {“a”:20,”b”:40}
[hp]
array_unshift() gains 10% with a 2 elements array, as above. With a 100 element array, it is faster by 50% over the previous implementation. It stays much slower than appending, by a factor of 3, and depending on the size of the array. So, it is better than before, and LIFO stacks are still a better performance choice.
array_walk() is in the same family as array_map(): it calls a userland callback from C, so the cost of re-entering the executor applies to it too. The entry does not say whether the gain comes from that path or from the array rebuild in array_unshift().
intval() with base 2
PHP syntax supports 0b for binary. Then, intval() can read a binary string when you pass base 2. PHP accepts an optional 0bprefix in that case, and the NEWS entry says the prefixed form is now faster.
echo intval(‘0b1010’, 0), “\n”; // 10
[hp]
The base-0 call and the base-2 call return the same value, and the prefix is what tells PHP the string is binary. Code that parses bit flags from a configuration file or a protocol field is the usual case where this matters.
pathinfo() with one component
pathinfo() normally builds an array with every part of a path: directory, basename, extension and filename. If you only want one of them, the rest of that array is wasted work. pathinfo() has been optimized when requesting only one of the elements.
echo pathinfo(‘/var/www/index.php’, PATHINFO_EXTENSION), “\n”; // php
[hp]
The output is the same as earlier PHP versions. The cost saving is in the function, not in your code, so no change to your calls is needed.
strsplit() and strpad()
str_split() cuts a string into fixed-size pieces, and str_pad() extends a string to a target length. Both build a new string or array from parts, and both sit in loops in formatting code. The NEWS entry says both are faster.
The first call shows that the last piece can be shorter than the rest. Padding a number to a fixed width, as in the second call, is common in invoice numbers and log lines, which is where a faster str_pad() pays off. The gain is about 20%, and grows with the size of the string, padded or split.
str_repeat() with a multiplier of 1
str_repeat() with a multiplier of 1 returns its input unchanged. The NEWS entry says that case is faster, which means the function can return the string without running the repeat loop. This helps with dynamically generated repeat values, which is a niche case.
echo str_repeat(‘ab’, 3), “\n”; // ababab
[hp]
new self() with known constructors
The NEWS entry says that arguments are passed more efficiently to known constructors, and gives new self() as the example. self always names the class the code is written in, so the compiler knows at build time which constructor runs.
{
public function __construct(public int $x, public int $y) {}public static function origin(): static
{
return new self(0, 0);
}
}
echo json_encode(Point::origin()), “\n”; // {“x”:0,”y”:0}
[hp]
Because the constructor is known, the call can pass its arguments directly, instead of going through a general lookup. The time measurement on this speed up was inconclusive.
Measuring it
The machine ran PHP 8.5.12 and PHP 8.6.0-dev side by side, on macOS with 8 cores. Each number is the best of five runs, each in a separate process, with the work inside a function. Within one table, the cases ran interleaved, so both versions saw the same load. Compare ratios within a table. Absolute times drift between runs.
The callback cases, 300 × 1,000 items per run:
| Case | PHP 8.5.12 (ms) | PHP 8.6-dev (ms) |
|---|---|---|
array_map(double(...), $arr) |
38.1 | 18.5 |
foreach calling double($v) |
57.9 | 17.6 |
array_map(intval(...), $arr) |
10.9 | 11.3 |
foreach calling intval($v) |
24.1 | 10.7 |
array_map(fn($x) =>; $x * 2, $arr) |
30.4 | 20.2 |
The first two rows are the callback the rewrite targets. On 8.6, the compiled array_map and the hand-written loop time the same, and so do the internal-callable pair. The rewrite itself costs nothing in speed. On 8.5, the hand-written loop was about 1.5 times slower than array_map. The 8.6 engine made the loop 3.3 times faster and array_map 2.1 times faster, so the gap closed.
The string cases, 1 million calls each, from one run:
| Case | PHP 8.5.12 (ms) | PHP 8.6-dev (ms) |
|---|---|---|
$s = $a (baseline) |
41.1 | 12.4 |
"$b" (int to string) |
25.9 | 61.5 |
$a . ' ' . $b |
43.6 | 127.1 |
"$a $b" |
34.2 | 116.7 |
sprintf('%s %d', $a, $b) |
36.0 | 129.0 |
The bare loop in 8.6 is four times faster than in 8.5. But anything that turns an integer into a string is two to three times slower on this 8.6 build, and that includes both sides of the printf comparison. This may be a regression in the dev snapshot.
The estimate
These estimates are for PHP 8.6 as it stood on the test machine. They are not release numbers, so there is still room for last-minute improvements.
printf to interpolation In the table, interpolation is 5% faster than sprintf() on 8.5 and 10% faster on 8.6. Estimate: a gain of 5 to 10% on the formatting line itself, and invisible inside a web request. No effort required for this one.
array_map with a first-class callable The rewrite to a loop is speed-neutral on 8.6: the same loop, the same time. The gain people will see comes from the 8.6 engine, which is about 2 times faster on this callback than 8.5. Estimate: 2 times faster on 8.6 against 8.5, for callback-heavy loops, with the rewrite itself adding nothing measurable. This makes array_map() a lot more useful now.
closures The rewrite does not reach them. My closure row uses an inline body (fn($x) =>; $x * 2), not double(), so it does not compare like with like. The one fair pair on 8.6 is the double() row and the foreach row, which match. I did not measure a first-class callable against a closure wrapping the same function, so I have no number for the closure penalty on 8.6. Write whichever reads better, and measure before you change working code for speed.
The bigger picture and the JIT
array_map() is a function, and the compiler now treats it as a loop. Why did it need to be a function at all? The answer is historical: array_map() predates the Closure, the first-class callable, and the JIT. It was written when the callback had to be a string name, and a loop was the only way to call it from userland. Each new feature has made the function more of a wrapper and less of a primitive.
If the rewrite gives no speed gain, what is it for? The loop is the same, but the JIT can now see the call inside it instead of a black box. The speed comes later, when the JIT has more to work with.
So the question for the next version is which other functions are doing the same thing. str_repeat() with a multiplier of 1 is an identity. pathinfo() with a single component is a substring. array_sum() on an integer array is a loop. If the compiler can see through those, the function call stops being a cost you pay and becomes an annotation you write for the reader.
Which of your hot functions would you be happy to see the compiler replace with a loop, and which would you want it to leave alone?

