Skip to content

PHP 8.4 in 2026: The Features That Stuck

PHP 8.4 in 2026: The Features That Stuck

Confession: I put off upgrading a client’s billing service to PHP 8.4 for almost a year because the last “big” PHP release burned me on a Friday deploy. So I sat on 8.3, watched the changelog from a safe distance, and told myself I’d get to it. I finally did the migration this spring, and now I’m a little annoyed at past me for waiting.

Some of what shipped in 8.4 is the kind of thing you use once and forget you ever lived without. Some of it I read the RFC for, nodded, and have never typed since. This is the honest version of that split. Not “10 amazing features” — just the ones that survived contact with real code, and the ones that didn’t.

If you’re still on 8.3 and wondering whether the jump is worth a sprint, here’s what I actually kept.

Property hooks deleted a whole class of getters

This is the one. If you only look at a single 8.4 change, make it this.

For years, the second you wanted a computed value or a validated write, you reached for a getter and a setter. Here’s the shape I’d written a thousand times:

class Temperature
{
    private float $celsius = 0.0;

    public function getFahrenheit(): float
    {
        return $this->celsius * 9 / 5 + 32;
    }

    public function setFahrenheit(float $f): void
    {
        $this->celsius = ($f - 32) * 5 / 9;
    }
}

Every caller then has to know it’s getFahrenheit(), not $temp->fahrenheit. Property hooks let you keep the plain property syntax and still run code on read and write:

class Temperature
{
    public float $celsius = 0.0;

    public float $fahrenheit {
        get => $this->celsius * 9 / 5 + 32;
        set(float $value) => $this->celsius = ($value - 32) * 5 / 9;
    }
}

Now $temp->fahrenheit reads and writes like a normal field, and the conversion lives with the data instead of in a pair of methods three screens apart. The property hooks RFC has the full grammar if you want the edge cases, including hooks declared on interface properties, which is genuinely useful for DTOs where you want to promise a value exists without dictating how it’s stored. I wrote up how this held up across a bigger codebase in my longer take on property hooks a year in, so I won’t relitigate all of it here.

The migration itself was calmer than I feared. Old getter methods keep working, so you can convert one property at a time and leave the rest alone. I did the whole billing service in an afternoon, one class per commit.

The one caveat: don’t put slow work in a get hook. It looks like a field, so callers assume it’s cheap. I cache anything that touches the database and expose it through a normal method instead, so the cost is visible at the call site.

Asymmetric visibility retired my readonly workarounds

Before 8.4, if I wanted a property the outside world could read but only the class could change, I had two bad options. Make it readonly and give up all mutation, or make it private and write a getter that does nothing but return it.

class Order
{
    private string $status = 'pending';

    public function getStatus(): string
    {
        return $this->status;
    }
}

Asymmetric visibility collapses that into a modifier:

class Order
{
    public private(set) string $status = 'pending';
}

$order->status is readable anywhere. Writing to it from outside the class is a compile error. Inside the class, transitionTo() or whatever your state machine is called can still mutate it freely. I use public protected(set) on aggregate roots so subclasses can adjust state but controllers can’t reach in and scribble on it.

The official 8.4 announcement lists this next to property hooks, and the two pair up nicely: hooks for computed and validated fields, asymmetric visibility for “read freely, write carefully” state. I went deeper on the state-machine angle in a separate post on asymmetric visibility, because it changed how I model orders more than I expected.

new without parentheses is a small daily joy

This one is pure ergonomics, and I didn’t think I’d care. I was wrong.

You know the double-paren dance where you instantiate something and immediately call a method on it?

$name = (new ReflectionClass($obj))->getShortName();

Those outer parentheses were never load-bearing. They were there to satisfy the parser. In 8.4 you drop them:

$name = new ReflectionClass($obj)->getShortName();

It reads the way you’d say it out loud. I hit this most in test setup and in one-liner factory calls, and after a week my hands stopped adding the parens automatically. Tiny change, but it’s the kind that removes a paper cut you’d stopped noticing.

The array helpers I keep reaching for

PHP’s array functions have always been a bit of a grab bag, and for the longest time there was no clean way to say “find the first element matching this.” You wrote the loop, broke early, and moved on:

$firstAdmin = null;
foreach ($users as $user) {
    if ($user->isAdmin()) {
        $firstAdmin = $user;
        break;
    }
}

8.4 added array_find, array_any, and array_all, and they cover the three questions I actually ask about a collection:

$firstAdmin = array_find($users, fn($u) => $u->isAdmin());
$hasAdmin   = array_any($users, fn($u) => $u->isAdmin());
$allActive  = array_all($users, fn($u) => $u->isActive());

array_find returns the element or null. array_any and array_all return booleans. The RFC for the new array find functions spells out the ordering guarantees, which matter if you care about which match you get back. I’ve swapped a lot of tiny foreach loops for these, and validation code reads noticeably better for it. There’s also array_find_key if you need the key instead of the value.

The honest limit: these still don’t short-circuit any better than a hand-written loop would, and they’re not lazy, so on a giant array you’re iterating the whole thing unless you get lucky with an early match. For the collection sizes I deal with day to day, request payloads, config lists, a few hundred rows, that’s a non-issue. I care more that the intent is legible at a glance than about shaving microseconds.

What I read the RFC for and then skipped

Not everything earned a place. Lazy objects landed in 8.4, and they’re clever: the runtime defers a heavy object’s initialization until something actually touches it, which is exactly what an ORM or a DI container wants under the hood. But that’s the thing. It’s a framework-author feature. In application code I never construct my own lazy proxies, because Doctrine and the container already do it for me. I’m glad it exists. I’ve just never typed newLazyGhost() in anger, and I doubt most app developers will.

The #[\Deprecated] attribute is the borderline case. Being able to mark a method deprecated and have the engine emit a warning is nice for library maintainers. In a normal app I lean on static analysis and code review for that, so it hasn’t changed my week. If you ship a package other teams depend on, it’s probably worth adopting.

So, is the jump worth it?

If you maintain PHP for a living, yes, and mostly for the first two features. Property hooks and asymmetric visibility together delete a genuinely tedious category of boilerplate, and they make your models say what they mean. The rest is a pleasant tailwind rather than a reason to upgrade on its own.

Here’s the concrete thing to do this week: pick one model class that’s mostly private fields wrapped in getters, and rewrite it with property hooks and private(set). One class. You’ll feel immediately whether it fits your codebase, and you’ll have a real diff to show your team instead of a changelog link. If you want to see how I think about this kind of incremental modernization on real projects, that’s most of what I do in my client work.

Start small, keep the old class in git, and let the diff make the argument for you.