Skip to content

PHP 8.4 in Practice: Property Hooks and Asymmetric Visibility โ€‹

PHP 8.4 came out on 21 November 2024, and it's had plenty of time to bed in. The hype was mostly about property hooks, and they've earned it. The feature I actually reach for more often is asymmetric visibility. And a couple of the small additions have quietly removed a lot of boilerplate.

This isn't a full changelog (the official release page does that well). It's what has actually changed the way I write backend code.


๐Ÿช Property Hooks: Getters and Setters, Built In โ€‹

Property hooks let you attach get and set logic straight to a property. Callers still write $customer->email. They don't need getEmail() or setEmail(), and you get no docblock that drifts away from the code.

php
final class Customer
{
    public function __construct(
        public string $email {
            set => strtolower(trim($value));
        },
    ) {}

    public string $domain {
        get => substr($this->email, strpos($this->email, '@') + 1);
    }
}

$customer = new Customer('  Jimmy@Example.COM ');
echo $customer->email;  // jimmy@example.com
echo $customer->domain; // example.com

The short set => expr; form assigns the result of the expression to the property. $domain is a virtual property: none of its hooks touch its own value, so PHP never stores one, and writing to it is an error.

Interfaces can also declare properties now. That is a bigger deal than it sounds:

php
interface HasSlug
{
    public string $slug { get; }
}

A word of caution, though. Hooks can't be combined with readonly. And it's easy to hide expensive work behind what looks like a plain property read. My rule is that a hook normalises or derives a value. If it touches the database, it should be a method.


๐Ÿ” Asymmetric Visibility: The Feature I Use Most โ€‹

For years, the answer to "public to read, private to write" has been a private property plus a getter. Now it's one line:

php
final class Order
{
    public function __construct(
        public private(set) string $status = 'pending',
    ) {}

    public function markPaid(): void
    {
        $this->status = 'paid';
    }
}

$order = new Order();
echo $order->status;    // pending
$order->status = 'paid'; // Error: cannot modify private(set) property

Some details are worth knowing:

  • It only works on typed properties.
  • private(set) makes the property implicitly final. Use protected(set) if subclasses need to write to it.
  • You can drop the public: private(set) string $status means the same thing.
  • readonly now implies protected(set) rather than private(set).

I've found this a better fit than readonly for entities and aggregates. Their state really does change over time, but only through methods that enforce the rules.


โœ‚๏ธ new Without Parentheses โ€‹

A small one, but I hit it every day. You no longer need to wrap a new expression in brackets to call something on it:

php
// Before 8.4
$date = (new DateTimeImmutable())->format('Y-m-d');

// PHP 8.4
$date = new DateTimeImmutable()->format('Y-m-d');

Builders, fluent query objects and test fixtures all read better for it.


๐Ÿ”Ž array_find, array_find_key, array_any, array_all โ€‹

These four functions cover things every codebase has written badly at least once. Each takes the array and then a callback, and the callback receives both the value and the key:

php
$users = [
    ['email' => 'a@example.com', 'active' => false],
    ['email' => 'b@example.com', 'active' => true],
];

$firstActive = array_find($users, fn (array $u): bool => $u['active']);
$index       = array_find_key($users, fn (array $u): bool => $u['active']); // 1
$anyActive   = array_any($users, fn (array $u): bool => $u['active']);      // true
$allActive   = array_all($users, fn (array $u): bool => $u['active']);      // false

They stop at the first decisive match, which array_filter() followed by reset() never did. array_find() returns null when nothing matches, so it pairs nicely with ??.


โš ๏ธ Deprecations to Fix First โ€‹

The deprecation most codebases hit first is implicitly nullable parameter types:

php
// Deprecated in 8.4
function send(Message $message, Options $options = null) {}

// Make it explicit
function send(Message $message, ?Options $options = null) {}

It's tedious rather than hard, and Rector's PHP 8.4 rules handle most of it. Vendor code is where it gets noisy, so update your dependencies before you start chasing warnings.

Other changes worth checking:

  • E_STRICT is deprecated, and so is passing E_USER_ERROR to trigger_error(). Throw an exception instead.
  • _ as a class name is deprecated, as is raising zero to a negative power.
  • lcg_value() and mysqli_ping()/mysqli_kill()/mysqli_refresh() are deprecated.
  • Changing session.sid_length and session.sid_bits_per_character is deprecated.
  • The IMAP, OCI8, PDO_OCI and pspell extensions have moved out of core to PECL. If you still use imap_*, check this before you bump your base image.

The 8.4 deprecations page has the full list.


๐Ÿงฐ Honourable Mentions โ€‹

  • #[\Deprecated] lets your own functions, methods and class constants raise real deprecation notices.
  • Lazy objects are mostly aimed at framework and ORM authors, but you benefit indirectly.
  • Dom\HTMLDocument finally gives PHP a spec-compliant HTML5 parser with querySelector().
  • BcMath\Number lets you use normal operators with arbitrary-precision maths, which is handy for anything involving money.

๐ŸŽฏ Conclusion โ€‹

PHP 8.4 isn't a revolutionary release, but it removes a lot of the ceremony that made PHP classes verbose. Asymmetric visibility has replaced most of my getters. Property hooks handle the rest when used with restraint. And the new array functions mean less hand-rolled looping.

If you're still on 8.2 or 8.3, the upgrade is mostly about clearing deprecations, and it's worth doing now. PHP 8.5 is due in November, and you'll want a clean deprecation log before then.