Modern PHP Features Most Developers Still Aren't Using (8.4 and 8.5)
You upgraded the runtime to PHP 8.5 but your code still reads like 7.4. Here are the modern PHP features (property hooks, asymmetric visibility, and array predicates from 8.4, plus the pipe operator, clone with, and array_first/array_last from 8.5) that most developers sleep on, with real syntax and the Magento tie-in.
Here's an uncomfortable truth: you upgraded to PHP 8.5, the current stable, released in November 2025, your pipeline is green, the runtime is faster, and your code still reads like PHP 7.4. The version number on the box changed; the way you write classes didn't. That's the whole point of this post. Of the headline modern PHP features from 8.4 and 8.5, most developers adopt exactly zero, because none of them are forced on you. Property hooks, asymmetric visibility, chained new, the new array predicates, the pipe operator, clone withthey all sit there, unused, while you keep writing the same getters and the same foreach-with-a-flag loops you wrote five years ago.
None of this is theoretical. Most of the day-to-day wins, property hooks and asymmetric visibility, are 8.4 features, and Magento 2.4.x officially supports PHP 8.3 and 8.4, so if you've done a recent upgrade you already have them in production. The newest additions land in 8.5. Let's go through the ones that earn their keep across both releases, and the deprecation that will bite a legacy codebase on the way in.
1. Property hooks kill your getters and setters
Property hooks are the marquee feature of PHP 8.4, and they're the one people are most reluctant to reach for, usually because the old habit is so ingrained. The pattern you've written a thousand times is a private property plus a getter that derives a value. With property hooks, the derivation lives on the property.
Here's the before, in classic 7.4 style:
class BookViewModel
{
private array $authors;
public function __construct(array $authors)
{
$this->authors = $authors;
}
public function getCredits(): string
{
return implode(', ', array_map(
fn (Author $a) => $a->name,
$this->authors,
));
}
}
// In the template:
// <?= $block->getViewModel()->getCredits() ?>And here's the same thing with a get hook. The constructor promotes the dependency, and $credits becomes a real, readable property that computes on access:
class BookViewModel
{
public function __construct(private array $authors) {}
public string $credits {
get {
return implode(', ', array_map(
fn (Author $a) => $a->name,
$this->authors,
));
}
}
}
// In the template:
// <?= $viewModel->credits ?>The difference isn't just fewer characters. A get hook is a typed, named property, so your IDE autocompletes it, PHPStan and Psalm understand its type natively, and you don't pay the readability tax of magic __get (which static analysis can't see into and which fires for every undefined property access). You get the ergonomics of a computed attribute with none of the magic-method drawbacks.
Hooks also work on writes, which is where validation finally has a clean home. A set hook runs whenever the property is assigned:
class Temperature
{
public float $celsius {
set (float $value) {
if ($value < -273.15) {
throw new \ValueError('Below absolute zero');
}
$this->celsius = $value;
}
}
}Now $t->celsius = -300; throws at the assignment site instead of somewhere downstream when bad data has already spread. No setter method, no __set, no surprise.
Property hooks shine for derived and validated values. They are not a license to hide expensive work behind a property access, something that looks like a field read shouldn't run three database queries. They can also be overused: bury enough logic behind innocent-looking reads and your class gets harder to follow, not easier. Use them where the cost is obvious and small; reach for an explicit method when the work is heavy or has side effects.
2. Asymmetric visibility makes DTOs honest
The second feature you're sleeping on is asymmetric visibility, another 8.4 addition, and it pairs perfectly with hooks. The idea: a property can be readable from anywhere but writable only from inside the class. You declare the write visibility in parentheses after the read visibility:
class Order
{
public private(set) string $status = 'pending';
public function markPaid(): void
{
$this->status = 'paid'; // allowed inside the class
}
}
$order = new Order();
echo $order->status; // 'pending': readable everywhere
$order->status = 'shipped'; // Error: cannot modify private(set) propertyThis is the death of the read-only getter. For a value object or DTO, you used to write a private property plus a getStatus() accessor purely to stop outside code from mutating state. Now the property is the public surface, and the type system enforces immutability from the outside while your own methods stay free to change it.
Two rules to keep in mind. First, the property must have a type declarationyou can't apply asymmetric visibility to an untyped property. Second, the write visibility must be equal to or more restrictive than the read visibility; public private(set) is legal, the reverse is not. There's also a shorthand: writing private(set) alone is the same as public private(set), since read defaults to public.
Asymmetric visibility is for data you want to expose but not let callers mutate. It's a much smaller hammer than readonly (which forbids all writes after initialization, even from inside). If your object legitimately changes state over its lifetime, an order moving through statuses,private(set) is the right tool and readonly is the wrong one.
You're on PHP 8.5 but writing PHP 7.4. The runtime upgraded; your muscle memory didn't.
3. Stop wrapping new in parentheses
This one is small, but it removes a wart you've typed for years. In every PHP before 8.4, calling a method directly on a freshly constructed object required parentheses around the whole new expression:
// Before 8.4: the parentheses dance:
$name = (new ReflectionClass($obj))->getShortName();PHP 8.4 lets you chain straight off new:
// 8.4:
$name = new ReflectionClass($obj)->getShortName();It reads better, it's one less pair of parentheses to balance, and it works for property access and array access too. Nothing deep here, just a paper cut that's finally gone. Once you start using it you'll stop noticing the old form was ever necessary.
4. array_find, array_any, array_all
For years, finding the first element matching a predicate meant either a foreach with a break, or an array_filter() followed by grabbing index 0 (which doesn't even work cleanly when keys aren't sequential). PHP 8.4 ships four first-class predicate functions that say exactly what you mean:
// First element matching the predicate, or null:
$first = array_find($posts, fn (Post $p) => strlen($p->title) > 40);
// First matching key (preserves the original key), or null:
$key = array_find_key($posts, fn (Post $p) => $p->isFeatured);
// Does at least one element match?: bool
$anyDraft = array_any($posts, fn (Post $p) => $p->status === 'draft');
// Do all elements match?: bool
$allLive = array_all($posts, fn (Post $p) => $p->status === 'live');Compare that to the loop you'd otherwise write:
// The old foreach-with-a-flag you can finally delete:
$anyDraft = false;
foreach ($posts as $p) {
if ($p->status === 'draft') {
$anyDraft = true;
break;
}
}The new functions short-circuit, array_any stops at the first match, array_all stops at the first failure, so they're not just shorter, they're as efficient as the hand-rolled loop. array_find returns the value (or null), while array_find_key returns the key. If you've been reaching for collection libraries just to get ->first(fn) semantics, a lot of that is now in the language.
5. Lazy objects, briefly
The most advanced of the new 8.4 modern PHP features is lazy objects, exposed through reflection via newLazyGhost() and newLazyProxy(). A lazy object is one whose state isn't populated until the first time you actually touch a property: the initializer you supply runs on demand, once.
$reflector = new ReflectionClass(HeavyService::class);
$service = $reflector->newLazyGhost(function (HeavyService $service): void {
// Runs only when a property of $service is first accessed.
$service->__construct(/* expensive dependencies */);
});
// Nothing has been initialized yet. The moment you read a
// property or call a method, the initializer above fires.Where does this pay off? Dependency injection containers and ORM hydration: any place you build an object the caller might never use. The API is verbose and you'll rarely write it by hand; it's primarily a tool for framework and library authors. But it's worth knowing it exists, because the frameworks underneath you are starting to use it, and it explains why an object you "created" might not be constructed yet.
PHP 8.5 (November 2025): three more worth adopting
Everything above shipped in 8.4. PHP 8.5 went GA on November 20, 2025, and it adds a small set of features that, true to the theme of this post, almost nobody has started using yet. Here are the three that change how everyday code reads.
The pipe operator |>
The headline PHP 8.5 pipe operator lets you feed a value through a series of callables left-to-right, top-to-bottom. Instead of the nested onion-call where you read the innermost function first, you read the steps in the order they run:
// The old onion, read inside-out:
$slug = str_replace(' ', '-', strtolower(trim($title)));
// PHP 8.5, read top-to-bottom:
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn (string $s) => str_replace(' ', '-', $s));Each step is a single-argument callable: a first-class callable (trim(...)), a closure, or any invokable, and the value flows left into each one. For a multi-argument function like str_replace you wrap it in a short closure so the piped value lands in the right slot. The result reads like a transformation pipeline instead of a parenthesis-counting exercise.
clone with: immutable updates in one expression
clone with is the feature that finally makes immutable value objects pleasant. Before 8.5, a "with" method had to re-list every constructor argument by hand, which is both noisy and a bug magnet the moment you add a property. Now you clone and override the changed fields in a single expression. Here's a PHP 8.5 clone with example:
final class Money {
public function __construct(
public readonly int $amount,
public readonly string $currency,
) {}
public function withAmount(int $amount): self {
return clone($this, ['amount' => $amount]);
}
}
$ten = new Money(1000, 'USD');
$twenty = $ten->withAmount(2000); // currency carried over automaticallyNo more manual new self($amount, $this->currency) that re-lists every constructor arg and silently drops one when you refactor. It pairs beautifully with 8.4 asymmetric visibility and readonly: the object stays immutable, and the only way to "change" it is to derive a new one.
array_first() and array_last()
These two are almost embarrassing in how overdue they are. PHP gained array_key_first() and array_key_last() back in 7.3, but the obvious companions that return the values never existed, so we reached for reset() and end(), both of which move the array's internal pointer as a side effect. PHP 8.5 makes them first-class:
$first = array_first($items);
$last = array_last($items);No internal-pointer mutation, no reset()/end() ceremony, and they return null on an empty array instead of false. If you've ever debugged a bug caused by an unexpected pointer move deep in a loop, array_first / array_last in PHP 8.5 are a quiet relief.
#[\NoDiscard]: don't ignore that return value
One more small but useful addition: the #[\NoDiscard] attribute emits a warning when a function's return value is thrown away, which is exactly what you want for a method whose result must be checked, say a save that can fail:
#[\NoDiscard('check the result')]
function persist(): bool { /* … */ }clone with plus readonly plus 8.4 asymmetric visibility make custom-module DTOs and value objects, say a \Panth\Catalog\Model\Data\PriceRange: genuinely immutable with almost no boilerplate: typed properties, no accessors, and a one-line with*() derivation. That combination is the single biggest reason to care about modern PHP features in 2026 for module code.
The deprecation that breaks your CI
Now the gotcha, because adopting a modern runtime isn't only about new toys, it's also about what it deprecates. The single most common thing that turns strict CI red on an upgrade is the implicitly nullable parameter. For years, giving a typed parameter a default of null implicitly made the type nullable. PHP 8.4 deprecates that:
// Deprecated since 8.4: emits a deprecation notice:
function save(Book $book = null) {}
// Correct: mark the type nullable explicitly:
function save(?Book $book = null) {}One missing ? per signature. It sounds trivial until you run it across a large Magento or legacy codebase, where this pattern appears in hundreds of method signatures: constructors, plugins, repository methods, framework subclasses. If your CI fails the build on deprecations (and it should), this is what lights it up. Treat it as a grep-and-fix sweep, not a per-file surprise:
// Find the offenders: typed param with a null default but no leading '?':
// grep -rEn '[(,][ ]*[A-Za-z_\\]+[ ]+\$[A-Za-z_]+[ ]*=[ ]*null' app/code
//
// Then add the '?' to each match. It's mechanical, but do it deliberately
//, don't let an automated rewrite touch union or intersection types.Do this pass first when you plan an upgrade and the rest of the migration gets a lot quieter. If you want the broader picture, see the Magento 2.4.9 upgrade guide for how the runtime bump fits into the full upgrade.
What this means in a Magento module
Here's where it gets concrete for Magento developers, because several of these features map almost perfectly onto patterns you write every day.
ViewModels are made for property hooks. A Block ViewModel exists to feed presentation values to a .phtml file. Today every value is a method call from the template. With a get hook, computed presentation data becomes a property: cleaner templates, and a ViewModel that documents its outputs as typed fields:
namespace Panth\Catalog\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
class ProductBadges
{
public function __construct(private ProductInterface $product) {}
public bool $isNew {
get => (bool) $this->product->getData('news_from_date');
}
public string $priceLabel {
get => $this->product->getTypeId() === 'configurable'
? 'From'
: '';
}
}
// Template:
// <?php if ($badges->isNew): ?><span class="badge">New</span><?php endif; ?>DTOs collapse to one-liners. Custom data objects in a Panth module (the little immutable carriers you pass between services) no longer need a field plus a getter per value. Asymmetric visibility makes the property itself the read-only public surface, and on 8.5 clone with gives you cheap immutable updates:
namespace Panth\Catalog\Model\Data;
final class ImportResult
{
public function __construct(
public private(set) int $imported = 0,
public private(set) int $skipped = 0,
public private(set) array $errors = [],
) {}
// PHP 8.5: derive a new result without re-listing every field.
public function withSkipped(int $skipped): self
{
return clone($this, ['skipped' => $skipped]);
}
}
// $result->imported is readable from the consumer,
// but only ImportResult itself can mutate it.That's a value object with zero accessor boilerplate, fully type-checked, immutable from the outside. The same class in 7.4 would have been three private properties and three getters.
A word of restraint, because none of this is a mandate to refactor your whole codebase on a Friday afternoon. Property hooks can obscure code if you bury logic behind innocent-looking property reads. Asymmetric visibility only works on typed properties, so it's not a drop-in for every legacy field. And there's no prize for converting ten thousand existing getters, do it as you touch the code, in new modules and in classes you're already editing. The win is writing the next class with modern PHP features in 2026, not rewriting every old one. If you're building new functionality, this is also a good moment to read up on clean Magento extension development patterns so the new syntax lands in well-structured modules rather than on top of old smells.
Frequently asked questions
Do property hooks hurt performance?
Not in any way you'll measure for typical computed or validated values. A get hook is a method call under the hood, so it costs roughly what a getter cost. The real performance risk is behavioral, not technical: if you hide expensive work (queries, network calls) behind something that looks like a property read, callers will access it in loops without realizing. Keep hooks cheap and obvious; use explicit methods for heavy work.
Can I use these in a Magento 2.4.x module?
The 8.4 features, property hooks, asymmetric visibility, the array predicates, yes. Magento 2.4.x officially supports PHP 8.3 and 8.4, so on a current install those are available in your custom modules right now. ViewModels are the natural home for property hooks and DTOs for asymmetric visibility. The 8.5-only syntax (pipe operator, clone with, array_first) only runs once your stack supports 8.5-check your framework's officially supported version before using it in module code. Confirm what you're actually running with php -v.
What did PHP 8.5 add over 8.4?
The headline additions are the pipe operator |> for left-to-right function chaining, clone with for one-expression immutable updates, the long-overdue array_first() and array_last() functions, and the #[\NoDiscard] attribute that warns when a return value is ignored. None of them are forced on you, which is exactly why so few codebases have adopted them yet. For everyday code, think of PHP 8.5 vs 8.4 as additive: 8.4 gave you the immutability primitives, 8.5 layers on the ergonomic syntax, and nothing from 8.4 is removed.
Is PHP 8.5 production-ready?
Yes: PHP 8.5 has been GA since November 20, 2025, and is fine for production once your dependencies support it. The caveat for Magento specifically: Magento 2.4.x targets PHP 8.3 and 8.4 today, so check your stack's officially supported version before jumping to 8.5 in production. Run your test suite, audit third-party packages, and clear any deprecation-as-error CI gates first.
What breaks when I upgrade from 8.2?
The most common breakage is the implicitly nullable parameter deprecation:function f(Type $x = null) now wants ?Type $x = null. In a large codebase that's hundreds of signatures and the top reason CI goes red. Beyond that, audit any code relying on previously-deprecated behavior and check that your static analysis and extensions support your target runtime. Most application code runs unchanged once the nullable-param sweep is done.
Should I rewrite all my getters?
No. There's no payoff in mass-converting working code, and a big-bang refactor just adds risk. Adopt hooks, asymmetric visibility, and clone with in new code and in classes you're already editing for another reason. The goal is writing the next class with modern PHP features, not churning every old one.
Upgrading a Magento store to PHP 8.4 or 8.5? I'm an Adobe-certified Magento & Hyvä developer and I do clean, CI-green upgrades.
Hire me