Skip to content
PHP

PHP 8.6 Partial Function Application: The Arrow Functions I Deleted

PHP 8.6 Partial Function Application: The Arrow Functions I Deleted

Short version for the impatient: PHP 8.6 lets you write str_replace('hello', 'hi', ?) and get back a closure with one parameter, properly typed, that you can hand to array_map or pipe into. If you want to know why I care this much about a question mark, read on.

I pulled the 8.6 Beta 2 image on Thursday because I wanted to see whether the pipe operator from 8.5 had finally become pleasant to use. I had a hunch it would, because the thing that made it awkward (wrapping every function that takes more than one argument in an arrow function) was exactly what partial function application was supposed to fix. Two hours later I had deleted eleven arrow functions from a single file in a client’s import pipeline and the file was easier to read. That almost never happens with a language feature. Usually the new thing is a lateral move, or it’s nice on paper and ugly in a code review. This one is a small and boring improvement, and a real one. I’d like to show you the before and after so you can judge it yourself instead of taking my word for it.

What partial function application actually is

The partial function application RFC puts it plainly: call a function with some of its arguments, and get back a closure that waits for the rest. PHP 8.1 gave us the degenerate version of this with first-class callable syntax, where strlen(...) returns a closure for strlen. What 8.6 adds is the ability to fill in some slots and leave others open.

Two placeholders exist. A ? means exactly one argument goes here later. A ... means “all the remaining parameters, whatever they are”. You can mix them with real values and with named arguments.

function stuff(int $i, string $s, float $f, int $m = 0): string { /* ... */ }

$a = stuff(1, ?, 3.5, ?);      // closure(string $s, int $m = 0): string
$b = stuff(1, 'hi', ...);      // closure(float $f, int $m = 0): string
$c = stuff(f: 3.14, s: 'two', ...); // closure(int $i, int $m = 0): string

The part I didn’t expect is how much the resulting closure inherits. Parameter names, types, defaults, by-reference flags, the return type, all of it comes from the underlying function. Reflection sees the real signature. Static analysers will too, once PHPStan and Psalm catch up, and that matters more than the syntax. An arrow function that wraps str_replace has whatever types I bothered to write. A partial of str_replace has the types str_replace has.

The before, in code I actually maintain

Here’s a shape that shows up in almost every import job I’ve written. Normalise a batch of strings, filter out the empties, map them through a formatter that needs configuration.

// PHP 8.4 style
$clean = array_filter(
    array_map(
        static fn(string $row): string => trim($row, " \t\n\r\0\x0B-"),
        $rows
    ),
    static fn(string $row): bool => $row !== ''
);

$labels = array_map(
    static fn(string $row): string => sprintf('%s (%s)', $row, $suffix),
    $clean
);

That’s not terrible. It’s the kind of code I’d wave through in a review. But look at what the arrow functions are doing: nothing. They exist purely because trim and sprintf take more than one argument and array_map only feeds one. Every one of those wrappers is a place where I once typed $rows instead of $row and spent ten minutes on it.

In 8.5 the pipe operator arrived and I tried to rewrite this style of code as a chain. It got worse, because every step still needed its wrapper, and now the wrappers were stacked vertically like a Jenga tower.

The after, with placeholders and pipes

Same logic on 8.6:

// PHP 8.6
$labels = $rows
    |> array_map(trim(?, " \t\n\r\0\x0B-"), ?)
    |> array_filter(?, static fn(string $r): bool => $r !== '')
    |> array_map(sprintf('%s (%s)', ?, $suffix), ?);

Read it top to bottom and it says what it does. Trim each row with these characters. Drop the blanks. Format each one. The single arrow function that survived is doing real work (a comparison), which is what arrow functions are for.

One thing that took me a minute: the ? in array_map(trim(?, ...), ?) appears twice and they mean different things. The inner one builds the trim closure. The outer one is the slot the pipe fills. The RFC’s rule is that a call with any placeholder in it returns a closure instead of executing, so array_map(x, ?) is itself a partial, and the pipe operator calls it with the value flowing down. Once that clicked, the chains stopped looking like magic.

I’m not sold on everything. The question mark is already overloaded in PHP (nullable types, the ternary, null coalescing), and a screenful with ? placeholders in a call sitting two lines above a ?string return type is going to confuse someone in a code review at some point. The RFC authors know this and chose it anyway because the alternatives were worse; I’d have argued the same. My other worry is the debugging story. A stack trace through a partial shows an extra closure frame with a generated name, and the first time one of these blows up in a queue worker at 2am I expect to spend a few minutes working out which frame is the partial and which is the real function. Neither of these is a reason to skip the feature. They’re the reasons I’d introduce it in one module first and see how the team reacts before letting it loose across a codebase.

The variadic rules are where I’d expect bugs in the wild. If your placeholders run into a variadic parameter, all the earlier placeholders become required, and the variadic slots get named $args0, $args1 and so on. I don’t think most people will hit this. I mention it because I hit it in about twenty minutes, wrapping a logger with string ...$context.

Readonly properties finally take defaults

The second feature I’d been waiting for is smaller and I suspect more people will use it day to day. Readonly properties can now declare default values. The readonly property defaults RFC is refreshingly honest about why this was banned before: the original readonly RFC said a readonly property with a default is basically a constant, so why bother. Interface properties in 8.4 changed that. Now you can satisfy a { get; } interface property with a fixed value and no constructor.

interface Ingestor
{
    public string $name { get; }
    public array $steps { get; }
}

// Before: a constructor that assigns constants, in every implementation
final class ChangelogIngestor implements Ingestor
{
    public function __construct(
        public readonly string $name = 'Changelog',
        public readonly array $steps = [ParseMarkdown::class, ExtractDates::class],
    ) {}
}

// PHP 8.6
final readonly class ChangelogIngestor implements Ingestor
{
    public string $name = 'Changelog';
    public array $steps = [ParseMarkdown::class, ExtractDates::class];
}

The catch, and it’s a fair one, is that the default counts as the initialising write. The property is set before your constructor body runs. Assign to it in the constructor and you get the usual “cannot modify readonly property” error. I got bitten by this on the second class I converted because I had a constructor that “normalised” the default. Delete the constructor. That’s the point.

The deprecations that will actually page you

Every PHP minor comes with a deprecation list and most of it is stuff nobody uses. Three items in the 8.6 UPGRADING notes are different, because I’ve seen all three in production code this year.

Returning from a finally block is now deprecated. If you have a return inside finally, it silently swallows whatever the try returned or threw, and I have personally debugged that for an afternoon. Good riddance, but grep for it before you upgrade.

spl_object_hash() is deprecated in favour of spl_object_id(). This one is everywhere in older code, usually as a cache key. The replacement is faster and returns an int, so anything that string-concatenates the hash will need a cast.

Mbregex (the mb_ereg family) is deprecated because the Oniguruma library underneath it is unmaintained. If you have legacy code doing multibyte regex through mb_ereg_replace, plan the move to preg_* with the u modifier now rather than in the 9.0 panic.

There’s also a deprecation on returning values from __construct(). I didn’t know that was allowed. Apparently it was.

Timeline, and whether to touch it yet

Per the release managers’ schedule (linked from the Beta 2 announcement), Beta 3 was due on 10 September, hard feature freeze lands on 22 September, RC1 on 24 September, and GA is targeted for 19 November. So the feature set is essentially locked as I write this. Syntax details could still move between betas, and the static analysis ecosystem hasn’t shipped support yet, which for me is the real blocker on using partials in anything with a CI gate.

What I’d do this week: run your test suite against the beta in a container. The php:8.6-rc tags on Docker Hub track the pre-releases. Don’t rewrite anything. Just look at the deprecation warnings, because the finally and spl_object_hash ones are cheap to fix now and annoying to fix under a deadline. I run this kind of upgrade sweep for clients as part of my Laravel and PHP consulting work, and the pattern is always the same: the language feature everyone talks about is the fun part, and the deprecation list is where the hours go.

If you run FrankenPHP in worker mode, be extra careful with the readonly defaults change. I wrote about static state that outlives a request earlier this year, and readonly properties with defaults on long-lived service objects are exactly the sort of thing that behaves differently when the process doesn’t die between requests. 8.6 didn’t create that problem. Worker mode did, and this is the same old lesson wearing a new hat.

One concrete task: open your codebase, search for static fn( inside array_map( and array_filter(, and count how many of those wrappers exist only to fix an argument position. That number is how much of your code gets shorter in November.