In February a colleague reviewed my CSV import rewrite and left exactly one comment, on the four lines I was most pleased with: “this was easier to read before.” He was half right, and working out which half took me the rest of the afternoon.
PHP 8.5 was released on 20 November 2025. The Symfony 8 application I spend most of my week in is pinned to PHP 8.4 and will stay there until the next quarter at least, so my 8.5 exposure is the import workers and a small internal tool, plus everything I write locally. Five months of that is enough to have an opinion about the pipe operator, which is the headline feature and the one I had the strongest expectations about.
What actually shipped
Being precise about this matters, because there was a lot of speculation in the run-up. From the new features page: the pipe operator |>, closures and first class callables in constant expressions, the #[NoDiscard] attribute with its matching (void) cast, attributes on non-class constants, #[Override] on properties, asymmetric visibility for static properties, backtraces on fatal errors, and the URI extension. clone became a function and takes a second parameter, so the with-er pattern on a readonly class is now clone($this, ['alpha' => $alpha]) instead of unpacking get_object_vars() into a constructor. cURL got share handles that survive across requests. And array_first() and array_last() landed, which means the polyfill I have copied into four projects can finally go.
The deprecation list is the part that generated work. The non-canonical cast names (boolean), (integer), (double) and (binary) are deprecated, which in our oldest import code was 31 occurrences of (integer) written by someone in 2016. The backtick operator is deprecated in favour of shell_exec(). $http_response_header is deprecated and you call http_get_last_response_headers() instead. __sleep() and __wakeup() are soft-deprecated in favour of __serialize() and __unserialize(). None of it was hard, all of it was an afternoon.
The chain I rewrote
Here is the actual code, minus the surrounding loop. Suppliers send us spreadsheets and the header row is never twice the same, so every header gets normalised before we try to match it to a field.
// Before, on PHP 8.4
$key = str_replace(
' ',
'_',
strtolower(
trim(
preg_replace('/[^A-Za-z0-9 ]+/', '', $header)
)
)
);
// After, on PHP 8.5
$key = $header
|> (fn (string $h) => preg_replace('/[^A-Za-z0-9 ]+/', '', $h))
|> trim(...)
|> strtolower(...)
|> (fn (string $h) => str_replace(' ', '_', $h));
The second version reads in the order the data moves. The first one you read inside out, starting at the innermost call and working back out through four closing parentheses, and if you add a step you re-indent everything. That is a real gain and I am not going to pretend otherwise.
Where it stops reading better
Look at what happened to two of the four steps. The operator takes a callable with a single parameter, and anything with more than one required parameter fails as if you had called it with too few arguments. trim() and strtolower() fit. preg_replace() and str_replace() take the subject last and the interesting arguments first, so each one needs a wrapping arrow function, and arrow functions have to be parenthesised or you get a fatal error. So half my chain is closures that exist only to reorder arguments.
That is not a small caveat, because PHP’s standard library puts the subject last almost everywhere. Your own single-argument functions pipe beautifully. Twenty years of string and array functions do not. The RFC is candid about this: the implementation compiles a first-class callable on the right into a direct call with no overhead, but an inline arrow function cannot be detected, so it is a real closure with real cost. Two of my four steps are free and two are not.
The other thing I got wrong twice: precedence. Pipe binds tighter than comparison and looser than arithmetic, which is what you want most of the time, and it is left-associative. But $id |> get_username(...) ?? 'default' applies the default to the result of the whole chain, not to the piped value, and I had to read that section of the RFC twice before I believed it. Passing by reference is disallowed outright, which is correct and which I only discovered by trying.
Where I have kept it
The import normaliser, a log line scrubber, and one place where we turn a raw response into a value object through three pure steps. All three had the inside-out shape and all three are better now. I reverted it in a controller where the chain was two calls long, because two nested calls were never hard to read and the pipe just added a line.
My rule after five months: use it where the chain is three steps or more, and where at least half the steps are already single-argument callables. If you are writing more arrow functions than pipes, the operator is not the thing that will make the code clearer. A small helper class with three named methods will.
Sources
- PHP 8.5 release announcement: the 20 November 2025 release date, plus the clone-with syntax, the
#[NoDiscard]attribute and persistent cURL share handles. - PHP 8.5 new features: the complete list of what landed, used to check every feature named above.
- PHP 8.5 deprecated features: non-canonical casts, the backtick operator,
$http_response_header,__sleep()and__wakeup(). - Functional operators in the manual: pipe semantics, the single-parameter rule, and the requirement to wrap arrow functions in parentheses.
- PHP RFC: Pipe operator v3: precedence, left associativity, the ban on pass-by-reference callables, and which callable forms compile down to direct calls.
array_first()manual page: confirms availability from PHP 8.5.0.