Contents

09/27/2026

PHP 8.5 pipe operator (|>): when it beats nested calls

PHP 8.5's pipe operator, |>, passes the value on its left as the only argument to the callable on its right, so $title |> trim(...) |> strtolower(...) runs in the order you read it. Use it when one value flows through three or more single-argument steps, and keep a plain call or a temporary variable when a step needs several arguments or you need to inspect a value along the way.

The problem it solves

Here is a slug function the way most PHP codebases still write it:

$slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', trim($title)), '-'));

It works, and nobody can read it. The first thing that happens is in the middle, the last thing is on the far left, and the second argument to the outer trim() is stranded forty characters away from the function it belongs to. The usual fix is a temporary variable reassigned four times, which reads top to bottom but spends a line of noise per step.

PHP 8.5, released on November 20, 2025, added a third option: the pipe operator, |>. It takes the value on its left and passes it as the only argument to the callable on its right.

<?php
declare(strict_types=1);

$title = '  PHP 8.5 Ships the Pipe Operator!  ';

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

echo $slug, PHP_EOL;

Run on PHP 8.5.5, all three versions (nested, temporary variable, pipe) print the same thing:

php-8-5-ships-the-pipe-operator

The pipe version reads in the order it executes, each step sits on its own line, and a step can be commented out or reordered without counting parentheses.

What goes on the right-hand side

Anything callable that accepts exactly one argument. The pipe operator RFC is deliberately that narrow. Closures, invokable objects, method callables and plain function names all qualify:

final class Upper {
    public function __invoke(string $s): string { return strtoupper($s); }
}
final class Wrap {
    public function __construct(private string $tag) {}
    public function apply(string $s): string { return "<{$this->tag}>{$s}</{$this->tag}>"; }
}

$b = new Wrap('b');
echo 'hello' |> new Upper() |> $b->apply(...) |> 'strrev', PHP_EOL;
>b/<OLLEH>b<

That is an invokable object, a first-class callable from a method, and a plain string function name, chained in one expression. The first-class callable syntax, strtoupper(...), is the one to reach for: the RFC notes that the compiler turns a pipe into a first-class callable into a direct function call, so it costs nothing over the nested version.

Where it beats nested calls

Transformations of one value, in sequence. String cleanup, normalizing input, turning a row into a DTO. These are the chains that turn into unreadable nesting, and they are exactly one value flowing through.

Array pipelines. Filter, pluck, map, de-duplicate is the classic inside-out mess. With pipes it reads as a list of steps:

$rows = [
    ['email' => ' Ann@Example.com ', 'active' => true],
    ['email' => 'bob@example.com',   'active' => false],
    ['email' => 'ann@example.com',   'active' => true],
    ['email' => 'Cy@Example.com',    'active' => true],
];

$emails = $rows
    |> (fn(array $r): array => array_filter($r, fn(array $row): bool => $row['active']))
    |> (fn(array $r): array => array_column($r, 'email'))
    |> (fn(array $e): array => array_map(fn(string $x): string => strtolower(trim($x)), $e))
    |> array_unique(...)
    |> array_values(...);

print_r($emails);
Array
(
    [0] => ann@example.com
    [1] => cy@example.com
)

Each step runs once, left to right, which you can confirm by piping through closures that print as they go:

function step(string $name): Closure {
    return function (int $x) use ($name): int { echo "$name($x) "; return $x + 1; };
}
$r = 1 |> step('a') |> step('b') |> step('c');
echo "=> $r", PHP_EOL;
a(1) b(2) c(3) => 4

Where it does not

When most steps need a wrapper. In the array example above, three of five steps are arrow functions, because array_filter, array_column and array_map all take more than one argument. That is still more readable than the nested form, but if every line is (fn($x) => something($x, $other)), a small named function or a collection class may say it better.

When the value is not the first thing you think about. A single call with two arguments, str_replace(' ', '-', $s), is not improved by being turned into a pipe of one.

When you need the intermediate values. A pipe is one expression. If you want to log or assert on step three, a temporary variable is still the honest tool.

The traps

Arrow functions must be parenthesized

Leave the parentheses off and PHP refuses to compile the file:

$r = 'abc' |> fn($s) => strtoupper($s) |> strrev(...);
PHP Fatal error:  Arrow functions on the right hand side of |> must be parenthesized

That is a good failure. Without the rule, the arrow function's body would swallow the rest of the chain, and strrev would run inside it rather than after it. The error is a compile-time one, so it surfaces the first time the file loads rather than on some rarely taken branch.

A function that needs two arguments fails at runtime

$r = 'a,b,c' |> explode(...);
PHP Fatal error:  Uncaught ArgumentCountError: explode() expects at least 2 arguments, 1 given

This one is not caught at compile time. Wrap it: |> (fn($s) => explode(',', $s)).

By-reference functions are refused

sort(), shuffle() and the other functions that modify their argument in place cannot sit on the right of a pipe:

$arr = [3, 1, 2];
$r = $arr |> sort(...);
PHP Fatal error:  Uncaught Error: sort(): Argument #1 ($array) could not be passed by reference

That is intentional: a pipe passes values forward, and a function that mutates its input and returns true has nothing useful to pass on. Use a wrapper that copies and returns: |> (function (array $a): array { sort($a); return $a; }).

Precedence sits between arithmetic and comparison

Arithmetic binds tighter than |>, so 1 + 2 |> (fn($x) => $x * 10) pipes 3, not 2:

$r = 1 + 2 |> (fn($x) => $x * 10);
var_dump($r);   // int(30)

Comparisons bind looser, so $x |> f(...) === 'ok' compares the result of the pipe. When a pipe sits inside a larger expression, parenthesize it anyway; the next reader should not need the precedence table.

What is coming next

The wrappers are the weak point, and the PHP Foundation's writeup on partial function application in PHP 8.6 addresses them directly: the RFC passed for PHP 8.6 and lets you write array_filter(?, isAdmin(...)) to leave one argument open, which turns most of the arrow functions above into a single placeholder. Until you are on 8.6, the parenthesized arrow function is the pattern, and it is still easier to review than the nested call it replaces. For the rest of what changed across PHP 8.x, see the modern PHP cheat sheet.

The short version

One value through three or more single-argument steps
Use |>, with first-class callables such as trim(...)
Most steps need an arrow-function wrapper
A small named function or a collection class may read better
One call with several arguments
Keep the plain call; a pipe of one adds nothing
You need to log or assert on a middle step
Use a temporary variable

Whichever you choose, parenthesize every arrow function on the right of |>.

Questions this raises

What does the pipe operator do in PHP 8.5?

It evaluates the expression on its left and passes the result as the single argument to the callable on its right, then returns what that callable returns. A chain such as $title |> trim(...) |> strtolower(...) runs left to right, one step per line.

Why does PHP say arrow functions on the right hand side of |> must be parenthesized?

Without parentheses, the arrow function body would swallow the rest of the chain, so later steps would run inside it. PHP refuses to compile the file instead. Wrap the closure: |> (fn($s) => trim($s, "-")).

Can I pipe into a function that takes two arguments, like explode()?

Not directly. The value is passed as the only argument, so explode(...) fails at runtime with ArgumentCountError. Wrap it in an arrow function that supplies the other argument. Partial function application, approved for PHP 8.6, will let a ? placeholder do the same job.

Is the pipe operator slower than nested function calls?

Not when the right-hand side is a first-class callable such as strtoupper(...). The RFC says those are compiled down to direct function calls, so they cost the same as the nested version.

Filed under