Skip to main content
← All articles
php

PHP 8.4 property hooks: a value object that validates itself

Laptop showing lines of code on a desk

Last week I was cleaning up a newsletter importer and found the same three lines copy-pasted into four places: trim the address, lowercase it, run filter_var(), throw if it fails. Every one of them had drifted slightly, and one forgot the lowercase step, which is how I ended up with Bob@Example.com and bob@example.com as two different subscribers. PHP 8.4 gives me a cleaner way to fix this: property hooks, plus asymmetric visibility, so the object enforces its own invariants.

Put the validation on the property

A set hook runs on every write to the property, including the one in the constructor. If the hook body references $this->email it is a "backed" property, so the value is really stored, and I get to normalise and validate before that happens. According to the manual's property hooks page, if the parameter type matches the property type you can omit it and use $value.

<?php

declare(strict_types=1);

final class Subscriber
{
    public string $email {
        set {
            $value = strtolower(trim($value));
            if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
                throw new \InvalidArgumentException("Invalid email: $value");
            }
            $this->email = $value;
        }
    }

    // Virtual property: no hook touches $this->domain, so it takes no storage.
    public string $domain {
        get => substr(strrchr($this->email, '@'), 1);
    }

    // Anyone can read it, only this class can write it.
    public private(set) int $failures = 0;

    public function __construct(string $email)
    {
        $this->email = $email;
    }

    public function recordFailure(): void
    {
        $this->failures++;
    }
}

Tracing it by hand: new Subscriber(' Bob@Example.com ') assigns to $this->email, the hook trims and lowercases, validates, and stores bob@example.com. $sub->domain then returns example.com, computed on read. Because domain has only a get hook that never references itself, it is virtual, and writing to it is an error. I did not run this snippet, so treat it as a traced example rather than something I benchmarked.

Why not readonly?

I reached for readonly first and it does not work here: the manual states that property hooks are incompatible with readonly, and points to asymmetric visibility as the alternative. That is what public private(set) on $failures does. Outside code can read the counter but $sub->failures = 0 throws, while the class itself can still mutate it. For a value that changes over the object's life that is usually what I actually wanted anyway.

Searching the result with array_find()

PHP 8.4 also added array_find(), array_any() and array_all(). array_find() returns the first value for which the callback returns true, or null if none match. Combined with the virtual domain property the importer code reads nicely:

<?php

$subscribers = [
    new Subscriber('alice@example.com'),
    new Subscriber(' Bob@Corp.example '),
];

$corp = array_find(
    $subscribers,
    fn (Subscriber $s): bool => $s->domain === 'corp.example'
);

$hasBouncing = array_any(
    $subscribers,
    fn (Subscriber $s): bool => $s->failures > 2
);

No more hand-rolled foreach with an early break just to grab one match.

Gotchas

Two things to watch. First, a hook only runs when the property is written, so every path into the object has to go through it; if the constructor used promotion, the manual notes the argument type must match the declared property type, not the wider type the hook accepts, which is why I kept a plain string argument and assigned inside the body. Second, hooks hide work behind what looks like a property read. A get hook that does a database query will surprise the next person. I keep hooks to cheap, pure transformations and validation, and use a method for anything else.

If you are on 8.4 or newer, this is a small refactor with an immediate payoff: one place for the rule, and an object that cannot exist in an invalid state.

BM
Blue Moose
The moose behind Blue Moose. Full-stack PHP developer — Drupal by day, Symfony by night, tests always.