Every PHP release ships a changelog longer than anyone reads, and most of it never reaches the code you write on a Tuesday. The interesting question is never “what is new”, it is “what changes a decision you were already making”. PHP 8.5 has a handful of features that do exactly that, and a pile of deprecations that will quietly turn into fatal errors two versions from now if you ignore them.
This is what we actually reach for in PHP 8.5 on real Symfony codebases, what we leave alone, and the small list of deprecations worth fixing before they become someone’s incident. It is not a changelog. It is a set of decisions.
Clone with: the feature immutable code was waiting for
If you build value objects the way we do, readonly classes with named constructors, you have written this method more times than you can count:
final readonly class Money
{
public function __construct(
public int $cents,
public string $currency,
) {
}
public function withCents(int $cents): self
{
return new self($cents, $this->currency);
}
}
Every with* method is the same shape: build a whole new instance, copy every property except the one that changed, and hope nobody adds a fourth property and forgets to thread it through all six copies. That last part is a real bug, and it is invisible in review because the code looks complete.
PHP 8.5 gives readonly objects a way out. clone with copies the object and overrides named properties in one expression:
public function withCents(int $cents): self
{
return clone($this, ['cents' => $cents]);
}
The override happens during the clone, before the readonly guard slams shut, so it is legal to rewrite a readonly property this one time and no other. Add a fourth property to the constructor and none of your with* methods need touching, because they only name the property they change. That is the whole point: the copy is no longer your responsibility.
We treat this as the new default for value object mutators. It removes the exact class of bug, a missed property in a hand written copy, that static analysis cannot always catch.
The pipe operator: real, but narrower than the demos
The pipe operator, |>, is the headline feature, and the demos oversell it. What it does is small and honest: it takes the value on the left and passes it as the single argument to the callable on the right.
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn (string $value): string => \str_replace(' ', '-', $value));
Read top to bottom, that is “take the title, trim it, lowercase it, replace spaces with hyphens”. The version it replaces is the one everyone hates, either deeply nested calls you read inside out, or a ladder of temporary variables that exist only to be passed to the next line.
The constraint that matters: every step must be a callable taking exactly one argument. strtolower(...) fits. Anything that needs a second argument does not, so you wrap it in an arrow function, as with str_replace above. That wrapping is where the elegance leaks, and it is why the operator is a good fit for genuine single argument transformation chains and a poor fit for most of what people will try to force through it.
We use it where the data really is a pipeline, normalising an input, transforming a string, threading a value through a few pure functions. We do not use it to make ordinary business logic look clever. If a reader has to stop and reparse the flow, the nested calls were more honest.
NoDiscard: catching the silently dropped return
Here is a bug that costs teams real time. A value object method returns a new instance, and a caller writes:
$money->withCents(500); // does nothing, and looks like it does
Because the object is immutable, the returned value is the entire point, and dropping it is always a mistake. Until 8.5 nothing told you. The #[\NoDiscard] attribute does:
#[\NoDiscard('the returned instance is the result; the original is unchanged')]
public function withCents(int $cents): self
{
return clone($this, ['cents' => $cents]);
}
Call it and drop the result, and PHP emits a warning pointing at the exact line. On the rare occasion you genuinely mean to discard it, you say so with a (void) cast, which documents the intent instead of hiding it.
The natural homes for this are every immutable with* method, and any function whose only job is to return something the caller must act on, a validation result, a command object, a builder step. It is a one line annotation that converts a whole category of silent no-ops into a visible warning your CI can fail on.
Closures in constant expressions: configuration without a factory
Before 8.5, a closure could not appear where PHP expects a constant expression: a property default, a class constant, an attribute argument. That forced a small amount of ceremony every time you wanted to attach behaviour to a declaration, usually a factory method or a match arm somewhere else in the file.
Now a static closure or a first-class callable is a legal constant expression:
final class RetryPolicy
{
public const array BACKOFF = [
'linear' => static fn (int $attempt): int => $attempt * 1000,
'exponential' => static fn (int $attempt): int => 2 ** $attempt * 1000,
];
}
The strategy lives next to the thing it configures instead of in a switch statement three files away. This is a quiet feature that will not change your architecture, but it removes a recurring papercut in exactly the declarative, attribute-driven style Symfony pushes you toward.
The small ergonomic wins
Two changes are too minor for their own section and too useful to skip.
array_first() and array_last() finally end the reset() and end() dance. Both of those move the array’s internal pointer, which is a side effect nobody wants when they are just reading a value, and the alternatives, $array[array_key_first($array)], are noise. The new functions read the first or last value and return null on an empty array, with no pointer games:
$latest = array_last($orderedResults); // null if empty, no side effect
final on promoted constructor properties closes a real gap. You could already promote a property in the constructor, and you could already mark a property final, but not both in one place. Now you can, so a property that must not be overridden in a subclass says so where it is declared:
public function __construct(
public final UserId $id,
) {
}
Neither of these changes a design. Both remove a small, daily friction, and small daily frictions are most of what makes a codebase tiring to work in.
The deprecations worth fixing this quarter
The features are optional. The deprecations are a schedule. Each one below is a warning in 8.5 and a fatal error in a future major, so the cheap time to fix them is now, while they are a grep and not an outage.
- Non-canonical cast names.
(boolean),(integer),(double), and(binary)are deprecated in favour of(bool),(int),(float), and(string). This is a pure find and replace, and there is no reason it should ever reach production as a warning. - The backtick operator. Backticks as an alias for
shell_exec()are deprecated. If you have them, they are almost certainly the least reviewed and most dangerous line in the file, so the deprecation is doing you a favour. Replace them with the Symfony Process component, which you already have. nullas an array offset. Usingnullas a key, including inarray_key_exists(), is deprecated in favour of an explicit empty string. This one hides in code that leans on loose typing, so it is worth a static analysis pass rather than a manual read.__sleep()and__wakeup(). Soft deprecated in favour of__serialize()and__unserialize(). Not urgent, but if you are touching a class that still uses the old pair, migrate it while you are there.
None of these are hard. The failure mode is not difficulty, it is that they sit unnoticed until a version bump turns a warning into a crash, and then they are urgent at the worst possible time. A codebase on PHPStan level 8 with the deprecation rules on will surface all of them in one run.
What we would turn on today
The short version. clone with is the new default for value object mutators, and it is worth a small refactor to adopt across your existing ones. #[\NoDiscard] goes on every immutable method and every function that returns a result the caller must use, and you want it there before the next silent no-op ships. The pipe operator earns its place in genuine transformation pipelines and nowhere else. Closures in constants, array_first and array_last, and final on promoted properties are free ergonomic wins you adopt as you touch the code.
And the deprecations get cleared this quarter, not the week before the 9.0 upgrade, because that is the difference between a grep and a death march.
If your codebase is a few PHP versions behind and the upgrade keeps sliding down the backlog, that is the work our technical debt engagement is built for. We map what a version jump actually touches, clear the deprecations that turn into fatal errors, and leave you on a footing where the next upgrade is a routine afternoon instead of a quarter-long project.
References
- PHP 8.5 release announcement : the official feature list, including clone with, the pipe operator, and the full set of deprecations referenced here.
- Clone with RFC : the design and semantics of
clone with, including why the override is legal on readonly properties during the clone. - Pipe operator RFC : the rationale and the single-argument callable constraint that shapes where the operator fits.
- NoDiscard RFC : the attribute, the warning it emits, and the
(void)cast that suppresses it deliberately.