Skip to content
PHP

What the PHP 8.5 Pipe Operator Fixed in My Codebase

What the PHP 8.5 Pipe Operator Fixed in My Codebase

Confession: I ignored PHP 8.5 for the first four months it was out. Not on principle. I had a Laravel app in front of me, a deadline behind me, and no appetite for a version bump whose headline feature was described as “URI extension.”

Then I opened a helper I’d written in 2023. Six nested function calls, read inside-out like a Russian doll assembled by someone who resented me personally. I wrote that file. It took me about ninety seconds to work out what it did.

That was the moment the pipe operator stopped being a syntax curiosity and started being a thing I wanted.

PHP 8.5 shipped on 20 November 2025. I’ve had it in production since roughly March. Here’s what stuck, what I got wrong, and the one feature I turned back off.

The pipe operator is a readability fix, not a functional programming conversion

Every time a language adds |>, half the timeline announces that the language is now functional and the other half announces that it’s ruined. Both are wrong. It moves the reading order.

Here’s the shape of the thing I actually rewrote. Old way:

$slug = strtolower(
    str_replace('.', '',
        str_replace(' ', '-',
            trim($title)
        )
    )
);

You read that from the middle out. trim runs first and it’s buried four levels deep.

PHP 8.5:

$slug = $title
    |> trim(...)
    |> (fn($str) => str_replace(' ', '-', $str))
    |> (fn($str) => str_replace('.', '', $str))
    |> strtolower(...);

Same result. Reads top to bottom, in execution order. The pipe operator RFC has the full semantics, and the short version is that |> takes the value on the left and passes it as the single argument to the callable on the right.

Two things bit me. First, the callable has to be single-argument, so anything with extra parameters needs an arrow function wrapper, which eats a chunk of the elegance. Second, and this is the real one: I went through a phase of piping everything. A 14-step chain is not more readable than a well-named intermediate variable. I now use it where the old version was genuinely nested, and nowhere else. That’s maybe fifteen places in a 60k-line codebase.

clone with quietly fixed the readonly pattern

This one I underrated in the changelog and now use constantly.

Readonly classes are great until you need a modified copy. Before 8.5 the “with-er” pattern meant a lap through get_object_vars and a splat:

public function withAlpha(int $alpha): self
{
    $values = get_object_vars($this);
    $values['alpha'] = $alpha;

    return new self(...$values);
}

That works and it’s ugly, and it breaks the moment someone adds a private property. Now:

public function withAlpha(int $alpha): self
{
    return clone($this, ['alpha' => $alpha]);
}

The clone() function takes an associative array of properties to override during the clone. It respects readonly, which is the entire point. I’d been avoiding readonly value objects in a couple of places purely because the with-er boilerplate wasn’t worth it. That excuse is gone.

If you’re doing DTO-heavy work in Laravel, this is the feature that pays for the upgrade on its own. I wrote about the N+1 side of that same codebase in how I catch N+1 queries in Laravel, and the value-object cleanup came out of the same refactor.

#[\NoDiscard] found a bug I’d been shipping for a year

#[\NoDiscard] marks a function whose return value matters. Call it and throw the result away, and PHP warns you.

#[\NoDiscard]
function withStatus(int $status): self
{
    return clone($this, ['status' => $status]);
}

$response->withStatus(404);
// Warning: The return value of method withStatus() should either be
// used or intentionally ignored by casting it as (void)

I put the attribute on our immutable response wrapper on a Tuesday afternoon, ran the test suite, and found one call site where somebody (fine, me) had treated a with-er as a mutator. It had been silently doing nothing since 2024. The endpoint returned 200 on a path that was supposed to return 404. No test caught it because the test asserted on the body.

I have mixed feelings about attribute-driven diagnostics generally. They’re easy to sprinkle everywhere and then you’re drowning in warnings you’ve trained yourself to ignore. My rule: #[\NoDiscard] goes on with-ers and on anything returning a Result-ish object. Nothing else.

The (void) cast exists for the cases where you genuinely don’t want the value, which I’ve needed exactly once.

The URI extension replaced a regex I should not have written

PHP finally has a first-party URI parser, built on uriparser for RFC 3986 and Lexbor for the WHATWG spec.

use Uri\Rfc3986\Uri;

$uri = new Uri('https://php.net/releases/8.5/en.php');
$uri->getHost(); // "php.net"

parse_url() still works and is still lenient in ways that will hurt you. It happily parses strings that aren’t valid URLs and returns an array with missing keys, so half the code around it is ?? null. The new class throws on garbage input, which is what I wanted the whole time.

I had a homegrown regex validating callback URLs on a webhook endpoint. It was forty characters long, I’d copied it from somewhere in 2021, and I never fully understood the second capture group. It’s gone now. That alone made the upgrade worth an afternoon.

Worth knowing: there are two classes, one per standard, and they normalise differently. If you’re comparing URLs for equality, pick one and be consistent, or you’ll get a fun bug where the same link matches itself only sometimes.

What I skipped, and the one thing I turned off

Persistent cURL share handles look genuinely useful if you’re hammering the same host repeatedly. I’m not, so I left them alone. Closures in constant expressions are a nice unlock for attribute-heavy frameworks, but that’s framework author territory, not mine.

The thing I turned off: I enabled the new deprecation warnings in a staging environment and immediately drowned. Casting null as an array offset is now deprecated, and an old caching layer did it in about two hundred places. Legitimate finding, wrong time. I’ve scheduled that as its own piece of work rather than letting it pollute every log line while I was trying to debug something else.

Also deprecated: backticks as a shell_exec() alias, and the non-canonical casts (boolean), (integer), (double), (binary). If your codebase is old enough to have (integer) in it, run a grep before you upgrade, not after.

Do this before you upgrade

Take fifteen minutes and grep your codebase for get_object_vars($this). Every hit is a with-er that clone() can replace. That’s the highest-value, lowest-risk change in the release, and unlike the pipe operator it doesn’t require you to have opinions about readability.

Then read the migration guide properly, particularly the deprecations section, before you flip the version in CI. I did it in the other order and it cost me a morning.

If you want to see how this kind of thing shows up in real client work rather than a blog post, that’s most of what I do on my projects page.