- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've written this line and read it backward to understand it:
$slug = strtolower(
str_replace(' ', '-', trim($title))
);
The data flows inside-out. trim runs first, but you read it last. PHP 8.5 shipped in November 2025 with five features that change small, daily habits like this one. Not the headline-grabber kind. The kind you reach for on a Tuesday and stop thinking about a week later.
Here is what each one replaces, with the 8.4 code it retires.
1. The pipe operator replaces nested calls and throwaway variables
The |> operator takes the value on the left and passes it as the single argument to the callable on the right. It reads top to bottom, in execution order.
// PHP 8.5
$slug = $title
|> trim(...)
|> (fn (string $s) => str_replace(' ', '-', $s))
|> strtolower(...);
Same result as the nested version. The difference is you read it in the order it runs. The right side of each |> is any callable: a first-class callable with ..., a closure, an invokable object.
Before 8.5 you had two options, and both had a cost. Nest the calls and read inside-out. Or spell out every step with a temp variable:
// PHP 8.4
$trimmed = trim($title);
$dashed = str_replace(' ', '-', $trimmed);
$slug = strtolower($dashed);
That works, but now you have three names for one value and three chances to reference the wrong one. The pipe drops the intermediates without hiding the order. One rule to remember: each callable takes exactly one argument, so bind extra arguments with a closure or a partial.
2. clone with replaces the broken wither pattern
Immutable objects want a wither: a method that returns a copy with one field changed. On a readonly class in 8.4, this is where you got stuck.
// PHP 8.4 — this throws
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
public function withAmount(int $amount): self
{
$copy = clone $this;
$copy->amount = $amount; // Cannot modify readonly
return $copy;
}
}
You could not reassign a readonly property, even on a fresh clone. So teams gave up on readonly for withers, or hand-rolled a full constructor call listing every field.
PHP 8.5 adds a second argument to clone: an array of property values applied during the copy, inside the class scope, so readonly fields are fair game.
// PHP 8.5
public function withAmount(int $amount): self
{
return clone($this, ['amount' => $amount]);
}
One line, no temp copy, and the class stays fully immutable. The array keys are property names; the assignment happens under the current visibility scope, which is why a wither inside the class can touch readonly fields that outside callers cannot.
3. #[\NoDiscard] replaces the docblock nobody reads
Some return values are the whole point of the call. A Result object. A new immutable copy. A validation outcome. Ignore it and the code compiles, runs, and silently does nothing.
In 8.4 your only defense was a comment and hope:
// PHP 8.4
/** @return self IMPORTANT: use the return value */
public function withStatus(Status $s): self { /* ... */ }
PHP 8.5 makes it enforceable. Mark the function #[\NoDiscard] and PHP emits a warning when the caller throws the return value away.
// PHP 8.5
#[\NoDiscard("the wither returns a new object")]
public function withStatus(Status $s): self
{
return clone($this, ['status' => $s]);
}
$order->withStatus(Status::Paid); // Warning: return value unused
$order = $order->withStatus(Status::Paid); // fine
When you mean to discard the value, cast it away with the new (void) cast so the intent is on the page:
(void) $order->withStatus(Status::Paid);
This lands hardest on immutable APIs and Result-style error handling, where a dropped return value is a bug that used to reach production unnoticed.
4. The URI extension replaces parse_url() guesswork
parse_url() has shipped with PHP for decades, and it has always been loose. It accepts malformed input, returns partial arrays, and does not follow any single standard. You end up writing defensive checks around every field.
// PHP 8.4
$parts = parse_url($input);
$host = $parts['host'] ?? null; // maybe there, maybe not
PHP 8.5 adds a real URI extension with two parsers, one per standard. Uri\Rfc3986\Uri follows RFC 3986. Uri\WhatWg\Url follows the WHATWG URL spec browsers use, so your PHP parses a URL the same way JavaScript does.
// PHP 8.5
use Uri\Rfc3986\Uri;
$uri = new Uri('https://user@example.com:8080/cart?id=9');
$uri->getScheme(); // "https"
$uri->getHost(); // "example.com"
$uri->getPort(); // 8080
$uri->getPath(); // "/cart"
Invalid input throws instead of handing back a half-filled array. The objects are immutable and give you typed accessors for every component. For anything security-adjacent, such as allowlisting a redirect host, a standards-compliant parser beats a lenient one that a crafted URL can fool.
5. Closures in constant expressions replace the lazy-init dance
Before 8.5, a constant expression could not hold a closure. That blocked the clean version of any construct that wanted an inline callback at declaration time: attribute arguments, default parameter values, static property initializers. You worked around it by passing null and building the closure later.
// PHP 8.4 — attribute takes a flag, logic lives elsewhere
#[Listener(async: true)]
final class OrderShipped {}
PHP 8.5 lets a static closure or a first-class callable live directly in a constant expression, so the condition sits next to the thing it configures.
// PHP 8.5
#[Listener(when: static fn (Context $c): bool
=> $c->isProduction())]
final class OrderShipped {}
The closure must be static and cannot carry a use clause, which keeps it a pure value with no captured state. It reads as configuration, evaluated when it is needed, without a second wiring step somewhere else in the codebase.
Which ones actually change your Tuesday
Not all five weigh the same in daily work.
- Pipe operator: high-frequency, low-risk. You will use it in transformation code the day you upgrade.
-
clone with: the one that unblocks immutable design. If you write value objects, this removes the reason people abandonedreadonlyfor withers. -
#[\NoDiscard]: small surface, real payoff onResulttypes and immutable APIs. Add it to the methods where a dropped return is a silent bug. -
URI extension: reach for it the moment you touch untrusted URLs. Retire
parse_url()there first. - Closures in const expressions: narrow, mostly a win for framework and attribute-heavy code.
None of these change your architecture. They change how much ceremony sits between your intent and the code that states it, which is most of what a language version is for.
Which one do you upgrade for first? If you run a value-object-heavy domain, my money is on clone with.
Two of these features push in the same direction the book does: immutable value objects and honest boundaries. clone with finally makes readonly withers painless, and a standards-compliant URI parser is the kind of concern you want pinned to the edge of your system, not smeared through your domain. Keeping the framework and the parsing at the boundary, and the invariants in the core, is the whole idea behind Decoupled PHP.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)