5 min read

The Pipe Operator in PHP 8.5: Cleaner Chaining Without the Nesting

PHP 8.5 ships the pipe operator (|>) for chaining callables left to right. Here is how it works, the rules it follows, and when it earns its place.

Featured image for "The Pipe Operator in PHP 8.5: Cleaner Chaining Without the Nesting"

If you have written PHP for any length of time, you have written code that reads inside out. You call one function, wrap it in another, wrap that in a third, and by the time you are done you are reading from the middle of the line toward both edges. PHP 8.5, released on November 20, 2025, adds a small piece of syntax that flips that around. It is the pipe operator, written |>, and its whole job is to let you read a chain of transformations in the order they actually happen.

The pipe operator does not give the language any new capability. Anything you can do with it, you could already do with nested calls or a pile of temporary variables. What it changes is readability, and for a certain shape of code that is a meaningful win.

The basic idea

The operator takes the value on its left and passes it as the single argument to the callable on its right. The result becomes the input for the next step in the chain. Here is the canonical example from the PHP.Watch writeup:

$result = "Hello World"
    |> strtoupper(...)
    |> str_shuffle(...)
    |> trim(...);

Read that top to bottom: start with a string, uppercase it, shuffle it, trim it. Now compare the two older ways of writing the same thing.

Nested, which you read from the inside out:

$result = trim(str_shuffle(strtoupper("Hello World")));

Or with temporary variables, which reads in order but is noisy:

$result = "Hello World";
$result = strtoupper($result);
$result = str_shuffle($result);
$result = trim($result);

The (...) next to each function name is the first-class callable syntax that landed back in PHP 8.1. It turns strtoupper into a callable reference rather than an immediate call, which is exactly what the pipe operator needs on its right side.

What you can pipe into

The right side accepts any callable, and you can mix kinds freely within one chain. Named functions, built-in functions, closures, arrow functions, static methods, instances that implement __invoke, and first-class callables all work:

$slug = " PHP Architect Weekly "
    |> trim(...)
    |> strtolower(...)
    |> (fn(string $s) => str_replace(' ', '-', $s))
    |> (fn(string $s) => preg_replace('/[^a-z0-9\-]/', '', $s));

// "php-architect-weekly"

One detail worth committing to memory: arrow functions and closures must be wrapped in parentheses when written inline in a chain. Leaving them bare produces a fatal error, so if you see a parse failure on a pipe, check your fn() first.

The callable on the right can even be an expression that returns a callable, which opens the door to resolving a handler at runtime and dropping it straight into the chain.

The rules that constrain it

The pipe operator is deliberately narrow, and understanding its limits keeps you out of trouble. Every callable in the chain has to accept exactly one required parameter, because the piped value is always passed as the first argument and there is no way to reposition it. If a function needs a second argument, you wrap it in a closure or arrow function that supplies the extra piece.

By-reference parameters are not allowed, since the intermediate values never live in a real variable. This is the trap that catches people:

explode("-", 'a-b-c') |> array_pop(...);
// Error: array_pop(): Argument #1 ($array) could not be
// passed by reference

array_pop, sort, array_push, and friends all mutate their argument in place, so none of them belong in a pipe. There are two odd exceptions in core, array_multisort and extract, which declare their references as @prefer-ref and therefore accept a direct value, but you are unlikely to reach for either mid-chain.

Type coercion behaves exactly as it does everywhere else. If you have declare(strict_types = 1) at the top of the file, the types have to line up or you get a TypeError:

declare(strict_types = 1);
$result = 1 |> strlen(...);
// TypeError: strlen(): Argument #1 ($string) must be of
// type string, int given

Functions with a void return type can appear in a chain, but their result is coerced to null, so they only make sense as the final step. Put one in the middle and everything downstream receives null.

Precedence, briefly

The pipe binds tighter than you might expect, which is usually what you want. In this line the arithmetic runs first, then the value flows down the chain:

echo 10 + 6 |> dechex(...) |> hexdec(...);
// 16

If you need a different grouping, parentheses do what they always do:

echo 10 + (6 |> dechex(...)) |> hexdec(...);
// 22

Comparison operators sit at a lower precedence, so === and friends behave the way you would read them, evaluating the whole pipe before the comparison. When in doubt, add parentheses and move on. Clarity beats cleverness here.

Where it earns its keep

The honest assessment is that the pipe operator is a readability tool, not a performance one. When PHP compiles a pipe chain it produces opcodes similar to the equivalent nested calls, so you are not trading speed for style in either direction. The copy-on-write behavior that PHP already uses for the temporary-variable version means that approach is not necessarily heavier on memory either.

So reach for it where a value passes through a clear sequence of single-argument transformations: normalizing user input, building a slug, running a string through a formatting pipeline, or composing a series of small pure functions. It reads like a recipe. Where your steps need multiple arguments, mutate their input, or branch, the pipe fights you, and ordinary code stays clearer.

The RFC that brought this in was authored by Larry Garfield and passed comfortably, with 33 votes in favor to 7 against. It is a small addition, and Garfield himself has framed it as a stepping stone toward a more function-composition-friendly PHP rather than a revolution on its own. That is roughly the right expectation. It will not change how you architect an application, but for the narrow band of code it fits, it makes intent obvious at a glance, and that is worth something on every line you read later.

Sources