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

Simulator · partial application

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.

partial taking $message. 1 evaluation of the supplied arguments, against 1 call. The last call returned [10:00:01] saved.

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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.