PHP 8.4 property hooks: a value object that validates itself
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.