{"id":575,"date":"2026-08-14T13:00:57","date_gmt":"2026-08-14T13:00:57","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/php-8-5-pipe-operator-clone-with-what-actually-stuck\/"},"modified":"2026-08-14T13:00:57","modified_gmt":"2026-08-14T13:00:57","slug":"php-8-5-pipe-operator-clone-with-what-actually-stuck","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/php-8-5-pipe-operator-clone-with-what-actually-stuck\/","title":{"rendered":"What the PHP 8.5 Pipe Operator Fixed in My Codebase"},"content":{"rendered":"<p>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 &ldquo;URI extension.&rdquo;<\/p>\n<p>Then I opened a helper I&rsquo;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.<\/p>\n<p>That was the moment the pipe operator stopped being a syntax curiosity and started being a thing I wanted.<\/p>\n<p>PHP 8.5 shipped on 20 November 2025. I&rsquo;ve had it in production since roughly March. Here&rsquo;s what stuck, what I got wrong, and the one feature I turned back off.<\/p>\n<h2 id=\"the-pipe-operator-is-a-readability-fix-not-a-functional-programming-conversion\">The pipe operator is a readability fix, not a functional programming conversion<\/h2>\n<p>Every time a language adds <code>|&gt;<\/code>, half the timeline announces that the language is now functional and the other half announces that it&rsquo;s ruined. Both are wrong. It moves the reading order.<\/p>\n<p>Here&rsquo;s the shape of the thing I actually rewrote. Old way:<\/p>\n<pre><code class=\"language-php\">$slug = strtolower(\n    str_replace('.', '',\n        str_replace(' ', '-',\n            trim($title)\n        )\n    )\n);\n<\/code><\/pre>\n<p>You read that from the middle out. <code>trim<\/code> runs first and it&rsquo;s buried four levels deep.<\/p>\n<p>PHP 8.5:<\/p>\n<pre><code class=\"language-php\">$slug = $title\n    |&gt; trim(...)\n    |&gt; (fn($str) =&gt; str_replace(' ', '-', $str))\n    |&gt; (fn($str) =&gt; str_replace('.', '', $str))\n    |&gt; strtolower(...);\n<\/code><\/pre>\n<p>Same result. Reads top to bottom, in execution order. The <a href=\"https:\/\/wiki.php.net\/rfc\/pipe-operator-v3\" rel=\"nofollow noopener\" target=\"_blank\">pipe operator RFC<\/a> has the full semantics, and the short version is that <code>|&gt;<\/code> takes the value on the left and passes it as the single argument to the callable on the right.<\/p>\n<p>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&rsquo;s maybe fifteen places in a 60k-line codebase.<\/p>\n<h2 id=\"clone-with-quietly-fixed-the-readonly-pattern\">clone with quietly fixed the readonly pattern<\/h2>\n<p>This one I underrated in the changelog and now use constantly.<\/p>\n<p>Readonly classes are great until you need a modified copy. Before 8.5 the &ldquo;with-er&rdquo; pattern meant a lap through <code>get_object_vars<\/code> and a splat:<\/p>\n<pre><code class=\"language-php\">public function withAlpha(int $alpha): self\n{\n    $values = get_object_vars($this);\n    $values['alpha'] = $alpha;\n\n    return new self(...$values);\n}\n<\/code><\/pre>\n<p>That works and it&rsquo;s ugly, and it breaks the moment someone adds a private property. Now:<\/p>\n<pre><code class=\"language-php\">public function withAlpha(int $alpha): self\n{\n    return clone($this, ['alpha' =&gt; $alpha]);\n}\n<\/code><\/pre>\n<p>The <code>clone()<\/code> function takes an associative array of properties to override during the clone. It respects readonly, which is the entire point. I&rsquo;d been avoiding readonly value objects in a couple of places purely because the with-er boilerplate wasn&rsquo;t worth it. That excuse is gone.<\/p>\n<p>If you&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/blog\/n-plus-1-query-problem-how-i-catch-it-in-laravel\" rel=\"noopener\">how I catch N+1 queries in Laravel<\/a>, and the value-object cleanup came out of the same refactor.<\/p>\n<h2 id=\"nodiscard-found-a-bug-id-been-shipping-for-a-year\">#[\\NoDiscard] found a bug I&rsquo;d been shipping for a year<\/h2>\n<p><code>#[\\NoDiscard]<\/code> marks a function whose return value matters. Call it and throw the result away, and PHP warns you.<\/p>\n<pre><code class=\"language-php\">#[\\NoDiscard]\nfunction withStatus(int $status): self\n{\n    return clone($this, ['status' =&gt; $status]);\n}\n\n$response-&gt;withStatus(404);\n\/\/ Warning: The return value of method withStatus() should either be\n\/\/ used or intentionally ignored by casting it as (void)\n<\/code><\/pre>\n<p>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.<\/p>\n<p>I have mixed feelings about attribute-driven diagnostics generally. They&rsquo;re easy to sprinkle everywhere and then you&rsquo;re drowning in warnings you&rsquo;ve trained yourself to ignore. My rule: <code>#[\\NoDiscard]<\/code> goes on with-ers and on anything returning a <code>Result<\/code>-ish object. Nothing else.<\/p>\n<p>The <code>(void)<\/code> cast exists for the cases where you genuinely don&rsquo;t want the value, which I&rsquo;ve needed exactly once.<\/p>\n<h2 id=\"the-uri-extension-replaced-a-regex-i-should-not-have-written\">The URI extension replaced a regex I should not have written<\/h2>\n<p>PHP finally has a first-party URI parser, built on <a href=\"https:\/\/uriparser.github.io\/\" rel=\"nofollow noopener\" target=\"_blank\">uriparser<\/a> for RFC 3986 and Lexbor for the WHATWG spec.<\/p>\n<pre><code class=\"language-php\">use Uri\\Rfc3986\\Uri;\n\n$uri = new Uri('https:\/\/php.net\/releases\/8.5\/en.php');\n$uri-&gt;getHost(); \/\/ &quot;php.net&quot;\n<\/code><\/pre>\n<p><code>parse_url()<\/code> still works and is still lenient in ways that will hurt you. It happily parses strings that aren&rsquo;t valid URLs and returns an array with missing keys, so half the code around it is <code>?? null<\/code>. The new class throws on garbage input, which is what I wanted the whole time.<\/p>\n<p>I had a homegrown regex validating callback URLs on a webhook endpoint. It was forty characters long, I&rsquo;d copied it from somewhere in 2021, and I never fully understood the second capture group. It&rsquo;s gone now. That alone made the upgrade worth an afternoon.<\/p>\n<p>Worth knowing: there are two classes, one per standard, and they normalise differently. If you&rsquo;re comparing URLs for equality, pick one and be consistent, or you&rsquo;ll get a fun bug where the same link matches itself only sometimes.<\/p>\n<h2 id=\"what-i-skipped-and-the-one-thing-i-turned-off\">What I skipped, and the one thing I turned off<\/h2>\n<p>Persistent cURL share handles look genuinely useful if you&rsquo;re hammering the same host repeatedly. I&rsquo;m not, so I left them alone. Closures in constant expressions are a nice unlock for attribute-heavy frameworks, but that&rsquo;s framework author territory, not mine.<\/p>\n<p>The thing I turned off: I enabled the new deprecation warnings in a staging environment and immediately drowned. Casting <code>null<\/code> as an array offset is now deprecated, and an old caching layer did it in about two hundred places. Legitimate finding, wrong time. I&rsquo;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.<\/p>\n<p>Also deprecated: backticks as a <code>shell_exec()<\/code> alias, and the non-canonical casts <code>(boolean)<\/code>, <code>(integer)<\/code>, <code>(double)<\/code>, <code>(binary)<\/code>. If your codebase is old enough to have <code>(integer)<\/code> in it, run a grep before you upgrade, not after.<\/p>\n<h2 id=\"do-this-before-you-upgrade\">Do this before you upgrade<\/h2>\n<p>Take fifteen minutes and grep your codebase for <code>get_object_vars($this)<\/code>. Every hit is a with-er that <code>clone()<\/code> can replace. That&rsquo;s the highest-value, lowest-risk change in the release, and unlike the pipe operator it doesn&rsquo;t require you to have opinions about readability.<\/p>\n<p>Then read the <a href=\"https:\/\/www.php.net\/manual\/en\/migration85.php\" rel=\"nofollow noopener\" target=\"_blank\">migration guide<\/a> 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.<\/p>\n<p>If you want to see how this kind of thing shows up in real client work rather than a blog post, that&rsquo;s most of what I do on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">projects page<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>PHP 8.5 has been in my production Laravel app since March. What the pipe operator, clone with and NoDiscard actually changed, and the one thing I turned off.<\/p>\n","protected":false},"author":2,"featured_media":574,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"PHP 8.5 has been in my production Laravel app since March. What the pipe operator, clone with and NoDiscard actually changed, and the one thing I turned off.","rank_math_focus_keyword":"php 8.5 features","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[52],"tags":[49,56,53,633],"class_list":["post-575","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-php","tag-backend","tag-laravel","tag-php","tag-php-8-5"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/575","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=575"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/575\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/574"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=575"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=575"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=575"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}