6 min read

Partial Function Application Lands in PHP 8.6, and the Pipe Operator Finally Makes Sense

PHP 8.6 ships partial function application. The ? and ... placeholders build closures from any callable, and they make the 8.5 pipe operator usable.

Featured image for "Partial Function Application Lands in PHP 8.6, and the Pipe Operator Finally Makes Sense"

PHP 8.1 gave us first-class callable syntax, and it was immediately useful:

$trimmed = array_map(trim(...), $lines);

It was also half a feature. trim(...) produces a closure over the whole function, all parameters intact. The moment you need to pre-fill one of them, you are back to writing a wrapper by hand:

$slugify = fn (string $input): string => str_replace([' ', '_'], '-', $input);

That closure exists purely to move an argument into position. You type the parameter, you name it, you repeat the return type, and none of it adds information the reader did not already have.

PHP 8.6 removes that step. Partial Function Application (v2), authored by Larry Garfield and Arnaud Le Blanc, passed 33 to 0 in December 2025 and was merged into master for 8.6 alpha 3. The same line becomes:

$slugify = str_replace([' ', '_'], '-', ?);

Feature freeze for 8.6 was August 13, 2026, so this is final. GA is November 19, 2026.

Two placeholders, one rule

There are exactly two symbols to learn.

? means one argument goes here later. Each ? becomes one parameter on the resulting closure, in the order it appears.

... means “everything I have not already specified.” It goes at the end, and it stands for zero or more parameters.

If the engine sees either one in a call, it does not call the function. It builds a Closure whose signature is derived from the parameters you left open, inheriting their names, types, default values, and by-reference status from the underlying function.

function schedule(string $track, string $title, int $minutes, string $room = 'Salon A'): Session {}

// One open slot.
$phpTekTalk = schedule('PHP Tek', ?, 50);

// Two, filled out of order by name.
$keynote = schedule(track: ?, title: ?, minutes: 60, room: 'Grand Ballroom');

// Positional prefix, then "the rest."
$tutorial = schedule('PHP Tek', ...);

Named placeholders reorder the closure’s parameters to match the order you wrote them. Positional placeholders and ... follow the original function’s order. Mixing them has one hard restriction: you cannot put a positional placeholder after a named argument. That is a compile error, not a runtime surprise.

Partials compose. Calling a partial with more placeholders narrows it further:

$byTrack   = schedule(?, ?, 50);
$phpTekOnly = $byTrack('PHP Tek', ?);

The pipe operator was waiting for this

PHP 8.5 added the pipe operator, and it has a strict requirement: the right-hand side must be callable with exactly one argument. In practice that meant wrapping most of your pipeline in closures, which is precisely the noise the pipe was meant to remove.

// 8.5, with wrappers
$slug = $title
    |> trim(...)
    |> (fn (string $s) => str_replace(' ', '-', $s))
    |> (fn (string $s) => str_replace(['.', '/', '&'], '', $s))
    |> strtolower(...);

// 8.6
$slug = $title
    |> trim(...)
    |> str_replace(' ', '-', ?)
    |> str_replace(['.', '/', '&'], '', ?)
    |> strtolower(...);

There is a real implementation payoff here, not just a cosmetic one. The RFC specifies that when a PFA call sits directly on the right side of a pipe and the compiler can prove it takes a single argument, the closure is optimized away entirely and compiled into a direct call. The forms that qualify are foo(?), foo(1, ?), foo(1, a: ?), and foo(1, ...). Anything ambiguous at compile time, like foo(a: 1, ...), builds a real closure.

Everywhere else, PFA is implemented by generating the AST of the equivalent closure, compiling it, and caching the result in opcache. Values you pre-fill are bound as use variables. Performance is essentially the same as writing that closure yourself, and stack traces, reflection, and debug output all behave like any other closure.

The optionality rule changed after the vote

This is the part worth internalizing, because it caught people who read the original RFC and stopped there.

The accepted v2 RFC said ? placeholders would inherit the optionality of the parameter they stood in for. A follow-up RFC, Handling of Optional Parameters by Tim Düsterhus, Arnaud Le Blanc, and Larry Garfield, reversed that. It passed 25 to 0 with 1 abstention in May 2026.

Every ? now produces a required parameter:

function publish(Article $a, string $status = 'draft', ?DateTimeImmutable $at = null) {}

$p = publish(?, ?);

// Effectively:
$p = static fn (Article $a, string $status) => publish($a, $status);
// NOT: fn (Article $a, string $status = 'draft') => ...

Parameters pulled in by ... still keep their defaults. Only ? is affected.

The reasoning is that publish(?, ?) visually promises two arguments, and code receiving a partial as “a callable with this signature” cannot rely on an invisible default. It also clears the way for a future RFC that partially applies the $this object of a method call, where a child class is free to give a parameter a default the parent never had.

The trap you will actually hit

The RFC documents one genuinely dangerous interaction, and it is worth reading twice.

Userland PHP functions silently ignore extra trailing arguments. Callbacks frequently pass more arguments than you asked for. Combine those with a ... placeholder and a function that has optional parameters, and you get this:

$firstNonZero = array_find($values, intval(?));

array_find() passes both the value and the key to the callback. With intval(?), the closure takes exactly one parameter, so the key is dropped and everything works. Write intval(...) instead and the key is forwarded straight into intval()’s $base parameter, where it means nothing sensible.

The rule of thumb: use ? for callbacks. Reach for ... when you actually want the remaining parameters, and check what the function does with them.

What does not work

new is out. Constructors are invoked indirectly by the engine, the implementation cost is high, and lazy objects in 8.4 already cover most of the motivating cases. Static factory methods work fine, which is where most of this lives anyway:

$makeSession = Session::forTrack(track: ?, conference: 'PHP Tek 2027');

The same handful of context-dependent builtins that first-class callables reject are also off limits: compact(), extract(), func_get_arg(), and get_defined_vars(). __get and __set cannot be partialed because they are not called as methods, but __call and __callStatic are supported and treated as function (...$args).

One subtle difference from arrow functions: argument expressions in a PFA call are evaluated immediately, when the closure is created, not when it is called. speak(?, getArg()) runs getArg() right away.

Thunks, for free

Because ... means zero or more, applying every argument and then adding it gives you a closure with nothing left to fill:

$rebuild = $indexer->rebuild($magazineId, $force, ...);

if ($shouldReindex) {
    $rebuild();
}

The literature calls this a thunk. It is a delayed call, and it is a nicer way to express “prepare this expensive operation but do not run it yet” than the usual closure-with-use dance.

Try it now

PHP 8.6 is in beta. Grab a build, run your test suite, and start with the pipelines and callbacks in your codebase where a closure exists only to shuffle one argument into place. That is where the win is immediate, and it is a large surface area in most applications.

Just do not ship it to production until November.

Sources: PHP RFC: Partial Function Application (v2), PHP RFC: Partial Function Application: Handling of Optional Parameters, PHP.Watch PHP 8.6 RFC list, Partial function application in PHP 8.6, stitcher.io.