PHP 8.6 Partial Function Application: Delete Half the Closures in Your Laravel App

PHP partial function application lands in 8.6. Use the ? and ... placeholders to delete closures from Laravel collections and pipes, and dodge the gotchas.

Steven Richardson
Steven Richardson
· 9 min read

Open any Laravel app and grep for fn (. You'll find hundreds of one-line closures that exist only to forward an argument: fn ($title) => Str::slug($title), fn ($v) => str_replace('_', ' ', $v), fn ($row) => $this->transform($row). They're noise with types attached.

PHP partial function application deletes most of them. The Partial Function Application v2 RFC passed 33–0 and ships in PHP 8.6, which reaches general availability on 19 November 2026 — the release candidates are out now, so this is the right time to learn the rules before you start rewriting.

What partial function application is#

Call a function, but replace one or more arguments with a placeholder. Instead of running the function, PHP hands you back a Closure with those parameters still open:

// A closure that takes one string and swaps spaces for underscores.
$underscore = str_replace(' ', '_', ?);

$underscore('hello there'); // "hello_there"

That's logically the same as writing the arrow function yourself:

$underscore = static fn (string $subject): string => str_replace(' ', '_', $subject);

The difference is that you don't restate the parameter name, its type or the return type — PHP derives the closure's signature from the underlying function. Reflection sees the real parameter names, types and by-reference flags, which matters more than it sounds once Laravel starts reflecting on your callbacks.

This isn't entirely new ground. You've been using the degenerate case since PHP 8.1: first-class callable syntax is partial application with every argument left open. strlen(...) was always a partial; 8.6 lets you fill in some of the blanks.

PHP partial function application: ? vs ...#

Two placeholders, two meanings:

  • ? means exactly one argument goes here.
  • ... means zero or more remaining arguments go here, and must be the last thing in the list.
function stuff(int $i, string $s, float $f, int $m = 0): string { /* ... */ }

// Open exactly the positions you mark. $m keeps its default and is not in the closure.
$a = stuff(1, ?, 3.5);       // fn (string $s): string

// Mark $m too and it becomes a REQUIRED parameter of the closure.
$b = stuff(1, 'hi', ?, ?);   // fn (float $f, int $m): string

// ... opens everything that's left, and keeps $m optional.
$c = stuff(1, 'hi', ...);    // fn (float $f, int $m = 0): string

// Everything bound plus ... gives a zero-argument closure. stuff() has not run yet.
$thunk = stuff(1, 'hi', 3.5, 9, ...);
$thunk(); // now it runs

Two rules are easy to miss. First, every ? produces a required parameter, even when it sits over an optional one — that's the follow-up optional parameters RFC, which also landed in 8.6. Second, an optional parameter you don't mention at all simply keeps its default and never appears in the closure's signature.

Unlike currying, you can open a parameter in the middle and bind the ones either side of it, which is exactly what $a does.

Getting the count wrong throws instead of failing silently. stuff(?) against four parameters, three of them required, throws an ArgumentCountError telling you the partial expects at least three arguments.

Named arguments and methods#

Named arguments work, with one twist. A named argument with a value behaves as normal, but a named placeholder reorders the closure's parameters to the order you wrote them:

// Bind by name, leave the rest open.
$c = stuff(f: 3.5, m: 9, ...);
// => fn (int $i, string $s): string

// Named placeholders set the parameter order of the closure.
$c = stuff(s: ?, i: ?, f: 3.5);
// => fn (string $s, int $i): string

Positional placeholders must come before named arguments. stuff(i: 1, ?, ?) is an error: "Cannot use positional placeholder after named argument."

Methods, static methods and closures all partial cleanly, and the closure inherits the scope it was created in:

// Static method — produces a static closure.
$toMoney = Money::of(?, 'GBP');
$prices = array_map($toMoney, $amounts);

// Instance method — the closure is bound to $order.
$markAs = $order->markAs(?, reason: 'bulk-update');
$markAs(OrderStatus::Shipped);

// Partials can be partially applied again.
$log = $logger->log(?, ?, ['tenant' => $tenant->id]);
$warn = $log(LogLevel::Warning, ?);
$warn('Rate limit approaching');

__call and __callStatic are supported too. PHP treats them as function (...$args), so the generated parameters are untyped and named $args0, $args1 and so on — which matters if you then try to call the partial with named arguments, or hand it to something that reflects on parameter types.

Partial function application with the pipe operator#

This is where the feature earns its keep. PHP 8.5's pipe operator needs a single-argument callable on the right, which meant wrapping anything with more than one parameter in a parenthesised arrow function. That wart is gone:

// PHP 8.5
$slug = $title
    |> trim(...)
    |> strtolower(...)
    |> (fn (string $s): string => preg_replace('/[^a-z0-9]+/', '-', $s))
    |> (fn (string $s): string => trim($s, '-'));

// PHP 8.6
$slug = $title
    |> trim(...)
    |> strtolower(...)
    |> preg_replace('/[^a-z0-9]+/', '-', ?)
    |> trim(?, '-');

There's a compiler optimisation here worth knowing about. When a partial sits directly on the right of a pipe and the compiler can prove it takes exactly one argument, the closure is never built — it compiles to a direct call. foo(?), foo(1, ?), foo(1, a: ?) and foo(1, ...) all get this treatment. Ambiguous shapes like foo(?, ?) or foo(a: 1, ...) build a real closure.

So the readable version is also the fast version, which is not a sentence I get to write often.

Does this replace Str::of($title)->trim()->lower()->slug()? Not for me. Fluent strings still win when Laravel already has the method. Pipes plus partials win when you're composing plain PHP functions or your own domain functions that Stringable will never know about.

Rewriting Laravel collection code with partials#

Most collection callbacks are one function call with one varying argument. Here's a pipeline before:

$routes = collect($names)
    ->filter(fn (string $name): bool => str_starts_with($name, 'admin.'))
    ->map(fn (string $name): string => str_replace('admin.', '', $name))
    ->map(fn (string $name): string => Str::headline($name))
    ->values();

And after:

$routes = collect($names)
    ->filter(str_starts_with(?, 'admin.'))
    ->map(str_replace('admin.', '', ?))
    ->map(Str::headline(?))
    ->values();

Three closures gone and the intent reads straight off the page. The same move cleans up Arr::map, array_filter and usort, and anywhere you pass a service method with a fixed option:

// Bind the constant argument once, vary the record.
$overdue->each($this->reminders->send(?, channel: 'email'));

// DTO factories with a fixed context.
$lines = $order->items->map(LineItem::fromModel(?, currency: $order->currency));

Event listeners are a nice fit too. When you pass a closure to Event::listen() with no event name, Laravel reflects on the closure's first parameter type to work out which event to listen for. A partial carries the real parameter types through, so this registers against OrderShipped exactly as a hand-written closure would:

// SlackNotifier::send(OrderShipped $event, string $channel): void
Event::listen($slack->send(?, channel: '#fulfilment'));

Partials are also legal in constant expressions, as long as their own arguments are constant, which extends what closures in constant expressions made possible in 8.5. Default parameter values are the obvious use:

final class PathNormaliser
{
    public function __construct(
        private Closure $trim = trim(?, '/'),
    ) {}
}

What you can't partially apply#

Constructors are out. new Order(?) is rejected, the same way new Order(...) already is for first-class callables. Partially apply a named static factory instead:

// Not allowed.
$make = new Order(?);

// Fine.
$make = Order::draft(?);
$orders = array_map($make, $customers);

The context-dependent built-ins are off the table as well — the same list first-class callables already reject: compact(), extract(), func_get_arg(), func_get_args() and get_defined_vars(). Their whole job is to inspect the calling scope, and a partial is a different calling scope.

__get and __set aren't calls, so there's nothing to partially apply there either.

Gotchas and Edge Cases#

Bound arguments are evaluated immediately. This is the one that will bite in production. An arrow function's body runs when the closure is invoked. A partial's bound arguments are evaluated when the partial is created:

function speak(string $who, string $msg): void
{
    printf("%s: %s\n", $who, $msg);
}

function getArg(): string
{
    print "evaluated\n";

    return 'hi';
}

$arrow = static fn (string $who) => speak($who, getArg());
print "marker\n";
$arrow('Steven');
// marker
// evaluated
// Steven: hi

$partial = speak(?, getArg());
print "marker\n";
$partial('Steven');
// evaluated
// marker
// Steven: hi

Swap getArg() for now() and you have a timestamp frozen at the moment the partial was built. $order->markAs(?, now()) registered in a service provider records boot time, not the time the order shipped — and an immutable Carbon instance won't save you, because the problem is when now() ran, not whether the object mutates (the DateTimeImmutable and CarbonImmutable guide covers the other half of that problem). If an argument has to be lazy, it belongs in a closure body.

? drops the collection key; ... forwards it. Laravel calls map(), filter() and each() callbacks with ($value, $key). A partial with no ... ignores extra arguments, so the key is discarded. A first-class callable or a ... partial forwards everything, and the key lands in the next optional parameter:

// Safe. The closure takes exactly one parameter; the key is ignored.
collect($titles)->map(Str::slug(?));

// Broken. Str::slug($title, $separator = '-') receives the key as
// $separator, so your slugs get joined with "0", "1", "2"...
collect($titles)->map(Str::slug(...));

Internal functions fail louder. collect($values)->filter(is_numeric(...)) throws ArgumentCountError because is_numeric() accepts exactly one argument and filter() passes two. filter(is_numeric(?)) just works. You may notice map(strtoupper(...)) doesn't throw — that's only because Arr::map() catches the ArgumentCountError and quietly retries without keys. Don't lean on that. The same key-swallowing hazard applies to array_find() and friends, which also pass the key, so intval(...) would get it as $base. For callbacks, prefer ?.

Variadics change the rules. Running positional ?s into a variadic parameter makes everything before it required, and the generated parameters are named after the variadic with a numeric suffix ($args0, $args1). Laravel's Dispatchable::dispatch(...$arguments) is variadic, so I'd keep job dispatch in a plain closure rather than reasoning through that.

Partials aren't serializable. They're closures. If a callback ends up in a queued closure listener, a cached config value or anything else that gets serialized, use an invokable class instead.

Tooling is still catching up. PHP-Parser added placeholder support in 5.9.0 on 13 September 2026, which unblocks everything built on it. PHPStan already runs on a PHP 8.6 runtime, but its release notes say full analyser support for 8.6 features is still to come. If you run PHPStan at level 10 in CI, check that your analyser, Rector and your formatter all parse a partial cleanly on one file before you rewrite hundreds.

Wrapping Up#

Start with the highest-density targets: collection callbacks, pipe chains and array_map/array_filter calls where the closure body is a single function call with one varying argument. Use ? rather than ... for anything a collection will call, and leave any closure with a lazy argument like now() exactly as it is.

Once your static analysis understands the syntax, this is an obvious codemod — automating the upgrade with Rector will convert the mechanical cases more reliably than find-and-replace, and if you're building multi-step flows, Laravel's Pipeline facade is the next place a partial can replace a wrapper closure.

FAQ#

What is partial function application in PHP 8.6?

Partial function application lets you call a function with only some of its arguments and get back a Closure that holds the rest open. Writing str_replace(' ', '-', ?) produces a closure that takes one string and performs the replacement, with its parameter name and types derived from str_replace() itself. It was accepted 33–0 and ships in PHP 8.6, due for general availability on 19 November 2026.

What is the difference between ? and ... in PHP partial application?

? is a placeholder for exactly one argument at that position, and ... is a placeholder for zero or more remaining arguments that must come last. Every ? becomes a required parameter, and a closure with no ... silently ignores any extra arguments passed to it. A ... keeps the remaining parameters' defaults and forwards extra arguments to the underlying function, so ? is the safer choice for collection callbacks.

How is partial application different from first-class callable syntax?

First-class callable syntax from PHP 8.1 is partial application with every argument left open, so strlen(...) is already a partial. PHP 8.6 generalises it: you can pre-bind some arguments, leave a middle parameter open, use named placeholders to reorder parameters, and build zero-argument thunks. In practice, use (...) when you want the whole function and ? when you want to fix some of its arguments.

Can I use partial function application with constructors?

No. new Order(?) is rejected, just as new Order(...) already is for first-class callables. Partially apply a static factory method such as Order::draft(?) instead, which works with instance methods, static methods and magic __call methods alike.

Does partial function application work with Laravel collections?

Yes, and collection callbacks are one of the best places to use it: collect($titles)->map(Str::slug(?)) replaces a whole arrow function. Laravel passes both the value and the key to map(), filter() and each() callbacks. A ? partial discards the key harmlessly, but Str::slug(...) would receive the key as its separator, and filter(is_numeric(...)) throws an ArgumentCountError, so stick with ?.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.