DEV Community

Cover image for The PHP 8.5 Pipe Operator Shipped: |> in Real Code, Not Just the RFC
Gabriel Anhaia
Gabriel Anhaia

Posted on

The PHP 8.5 Pipe Operator Shipped: |> in Real Code, Not Just the RFC


You've written this line. A raw string comes in, and you clean it up in
one expression:

$name = ucwords(strtolower(trim($raw)));
Enter fullscreen mode Exit fullscreen mode

To read it, your eye starts in the middle, at trim($raw), then walks
outward: strtolower, then ucwords. The order you read is the reverse of
the order it runs. Three functions is fine. Six is a headache, and the
usual escape hatch is a stack of temp variables that exist only to name
the space between steps.

PHP 8.5 shipped in November 2025 with the pipe operator, |>, from
Larry Garfield's RFC. It
turns that inside-out expression into a top-to-bottom one.

What |> actually does

The rule is short. $value |> $callable evaluates to
$callable($value). The right side is any expression that produces a
callable. PHP calls it with the left side as its single argument. The
operator is left-associative, so a chain runs left to right.

That means the nested version above becomes:

$name = $raw
    |> trim(...)
    |> strtolower(...)
    |> ucwords(...);
Enter fullscreen mode Exit fullscreen mode

The trim(...) syntax is the first-class callable form from PHP 8.1. It
builds a closure that forwards its argument to trim. That closure is
what |> invokes. Read the chain from $raw down and you see the data
flow in execution order. No middle-out reading, no intermediate names.

The result is identical to the nested call. Same functions, same order,
same output. What changed is which direction your eye travels.

Where it reads better

The pipe shines when every step is a function of one argument that
returns the next input. Scalar cleanup is the clearest case. Here's a
normalization pass on a user-supplied tag:

$tag = $input
    |> trim(...)
    |> strtolower(...)
    |> (fn(string $s): string
        => preg_replace('/[^a-z0-9]+/', '-', $s))
    |> (fn(string $s): string => trim($s, '-'));
Enter fullscreen mode Exit fullscreen mode

Compare that to the nested form, which you'd have to read from the
innermost trim outward while counting parentheses. Or to the temp-var
form, where you invent $trimmed, $lowered, $dashed names that carry
no meaning past the next line. The pipe keeps the sequence flat and drops
the throwaway names.

It also composes with your own functions. If each transform is a small,
named, pure function, the chain reads like a recipe:

$report = $rows
    |> normalizeRows(...)
    |> dropEmpty(...)
    |> toReportLines(...)
    |> renderMarkdown(...);
Enter fullscreen mode Exit fullscreen mode

Each stage is testable on its own. The pipe just wires them. This is the
case the RFC was built for, and it holds up in real code.

Where it hides intent

Look back at the tag example. Two of the four steps are closures, and
they are there for one reason: preg_replace and the two-argument
trim need more than the single value the pipe hands them. PHP has no
partial application, so the moment a step needs a second argument, you
wrap it in a fn.

That wrapping is where the pipe starts to obscure things:

$clean = $raw
    |> (fn(string $s): string
        => str_replace(' ', '_', $s))
    |> (fn(string $s): string
        => substr($s, 0, 50));
Enter fullscreen mode Exit fullscreen mode

A reader now has to parse two closures to learn that you replaced spaces
and truncated. The plain version says it in fewer moving parts:

$clean = substr(str_replace(' ', '_', $raw), 0, 50);
Enter fullscreen mode Exit fullscreen mode

When more than half your stages are closures dressing up multi-argument
functions, the pipe is adding ceremony, not removing it.

Debugging is the other soft spot. With temp variables you drop a
var_dump($lowered) between two lines and inspect the stage. In a pipe
there is no line to stand on. You either break the chain apart again or
splice in a tap that returns its input untouched:

$out = $in
    |> normalize(...)
    |> (function ($x) { dump($x); return $x; })
    |> finalize(...);
Enter fullscreen mode Exit fullscreen mode

That works, but it's noise you added to see inside your own expression.
A chain you have to dismantle to debug is a chain that's hiding state
you needed.

Side effects are the third trap. The pipe reads as a pure data flow, so
a step that returns void or null for its effect silently poisons the
rest of the chain by passing that value forward. Keep every stage a pure
transform that returns the next input. If a step is there to write a log
line or dispatch an event, take it out of the pipe.

The precedence you should not guess

The pipe binds loosely. That's usually what you want, but it means an
inline expression on the left can surprise you:

$result = $base + $offset |> formatCents(...);
Enter fullscreen mode Exit fullscreen mode

The addition happens first, then the sum is piped. If that's what you
meant, fine. If you meant something else, you now have a bug that reads
fine. Don't rely on remembering the precedence table. When the left side
is anything more than a single variable or call, wrap it:

$result = ($base + $offset) |> formatCents(...);
Enter fullscreen mode Exit fullscreen mode

Explicit parentheses cost nothing and remove the question.

Pipe versus a real pipeline object

For scalars and small function chains, |> is the right tool. For a
stream of objects moving through configurable stages, a pipeline class
still wins. Laravel's Pipeline,
league/pipeline, and the
middleware stacks in most frameworks give you named stages, dependency
injection
into each stage, and a place to hang tests and metrics. The pipe
operator has none of that. It's a language feature for expressions, not
an architecture for request handling.

A useful line to hold: reach for |> when you'd otherwise write nested
calls or temp variables for a handful of pure transforms. Reach for a
pipeline object when the stages carry dependencies, need ordering
config, or process domain objects rather than scalars.

When to reach for it

The pipe pays off in a narrow, common band:

  • Scalar normalization: trimming, casing, slugifying, formatting.
  • Replacing three-or-more-deep nested single-argument calls.
  • Wiring small named pure functions into a readable sequence.

It costs you when:

  • Most steps need extra arguments and become closures.
  • You need to inspect intermediate values often.
  • A step has a side effect or returns nothing.

The |> operator is not going to reshape how you write PHP. It removes a
specific papercut, the inside-out read of nested calls, and it introduces
a specific temptation, hiding multi-argument logic inside closures that a
plain expression would state more clearly. Use it where the data flow is
genuinely a straight line, and drop back to named steps the moment it
isn't.


If this was useful

The pipe is a formatting-and-transformation tool, and that's exactly the
kind of concern that belongs at the edge of your system, in the adapters
that clean input and shape output, not tangled into your domain rules.
Keeping transformation at the boundary and business logic in the core is
the whole idea behind hexagonal design, and it's what Decoupled PHP
walks through in full, from ports and adapters to the seams that let your
code outlive whichever framework you're on this year.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)