References & memory
PHP 8.6 partial application: why the timestamp never moves
The mistake
PHP 8.6 lets you write a call with holes in it. Put ? where an argument should
go and you get back a closure that takes the missing ones:
$slug = str_replace(' ', '-', ?);
$slug('Hello World'); // 'Hello-World'
It reads like a shorter way to write fn ($s) => str_replace(' ', '-', $s), and
with literals in the other positions it behaves like one. Put a function call
there instead and the two stop agreeing, in a way nothing at the call site shows
you.
The machine
Every value runs on the tested reducer, and every claim it makes is checked against a real PHP 8.6 by docs/verify-php86.sh. The clock is a counter the calls advance, never a real one.
Drive it
Call it again, and read the timestamp on the second line.
Call it once more. The clock has not moved, because it was read before the first call happened.
Now tick “write it as an arrow function” and call it three times.
The mechanism
The arguments you supply are evaluated once, when you create the partial. The closure keeps the values it got, not the expressions that produced them.
$logAt = log_line(?, now()); // now() runs here, once
$logAt2 = fn ($m) => log_line($m, now()); // now() runs on each call
$logAt will return the same timestamp for the rest of its life. $logAt2 reads
the clock on every call. The difference is when the work happens, and the two lines
look identical.
That matters for anything in a supplied position that is not a plain value: a
clock, a counter, a request id, a database read, a config() lookup before the
config was overridden. Each of them happens once, at the moment the partial is
built, which is often much earlier than you think and usually not where the
closure is used.
The second difference is the signature. Placeholders written by name reorder the closure’s parameters into the order you wrote them, not the order the function declares:
function tag(string $name, string $text, string $class): string { /* ... */ }
$badge = tag(text: ?, name: ?, class: 'lead');
$badge('Hi', 'p'); // '<p class="lead">Hi</p>'
tag() takes the name first. $badge takes the text first, because that is the
order the placeholders appear in. Pass them the function’s way round and you get
<Hi class="lead">p</Hi>, with no error, because both arguments are strings.
... is a placeholder for everything that is left, so tag('p', ...) gives a
closure taking the remaining two parameters. That is the same feature as the
first-class callable syntax from PHP 8.1: tag(...) is a call whose every argument
is a placeholder, which is why it produces a Closure taking all three.
And a partial is not curried. Calling it with fewer arguments than it has
placeholders is an ArgumentCountError, not another partial.
In your code
Supply values, not calls.
// the value is read once, at build time, and frozen
$logAt = log_line(?, now());
// read it per call
$logAt = fn (string $m): string => log_line($m, now());
// or make the freezing deliberate and obvious
$startedAt = now();
$logAt = log_line(?, $startedAt);
The third form does the same thing as the first. It is better because the capture is written down, so the next reader does not have to know this page to see it.
The fine print
Every claim here was measured against PHP 8.6.0RC2. docs/verify-php86.sh runs
these exact expressions in a container and compares the output, and the reducer’s
spec restates what that script asserts.
PHP 8.6 is stable on 19/11/2026, so the semantics are frozen but the exact
wording of an error can still move before then.
The clock in the simulator is a counter that your calls advance. It is not a real clock, because a timestamp that moved on its own would make the demonstration a coincidence rather than a rule.
Left out: the pipe operator |>, which composes with partials and has its own
surprises; what the closure inherits about by-reference parameters and defaults,
which it does, straight from the underlying function; and the positions where a
placeholder is rejected outright, including new, compact() and
func_get_args(). The simulator also will not let you write an invalid call, so
the argument-count errors are described here rather than produced there.
Further reading
- RFC: partial function application is the specification, including the evaluation-order rule this page is about and the full list of rejected positions.
- PHP 8.6 UPGRADING notes is where to check what actually shipped, rather than any summary of it, including this one.
- Cogway: assignment, references and copy-on-write is the same question one layer down: when PHP copies a value and when it shares it.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.