Value objects
The newsletter has two subscribers called Ann. One is ann@example.com and the other is Ann@Example.com , typed on a phone with a trailing space. She gets every email twice and unsubscribes from both.
Nothing is wrong with any single line of that code. The problem is that an email address is a string, and a string has no idea what an email address is. Every method that takes one has to decide again whether it's valid, whether case matters, and whether ' ' counts. Some of them forget.
A string can't tell you what it means
subscribe(string $email, string $name) has three problems that no type check catches:
- Validity. Any string fits, including
''and'n/a'. - Equality.
'Ann@Example.com 'and'ann@example.com'are different strings and the same person. - Mix-ups.
subscribe($name, $email)is two strings in the wrong order, and PHP and PHPStan are both happy with it.
A value object fixes all three. It's a small immutable class that wraps the value, checks it once when it's built, and is compared by what it holds rather than which instance it is:
final readonly class EmailAddress { private function __construct(public string $value) {} public static function fromString(string $email): self { $email = strtolower(trim($email)); if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { throw new InvalidArgumentException("Not an email address: {$email}"); } return new self($email); } public function equals(self $other): bool { return $this->value === $other->value; } }
The private constructor from 2.2 means there's no way to get an EmailAddress without going through fromString(). So anything typed EmailAddress is valid and already normalised, and nothing downstream has to check again.
The third problem goes away by itself. Once subscribe() takes EmailAddress $email, string $name, passing them the wrong way round is a type error, and PHPStan reports it on the line that does it.
Equal by value
Two EmailAddress objects built from the same address are different instances, so === says they're different. That's correct for an entity, like a customer, where identity matters. For a value, identity doesn't matter: a £5 note is a £5 note. Give value objects an equals() method and use it.
Where a value needs to be an array key, use the normalised ->value. Then the array can't hold two keys for one address, because there's only one way to write it.
When it's worth it
Not every string needs a class. Reach for a value object when a value has rules (what's valid, what counts as equal), when it travels between several classes, or when it sits next to other values of the same primitive type that could be swapped. Email addresses, SKUs, order references and money all qualify. A label shown once on a page doesn't.
Power-up
<?php final class Newsletter { /** @var array<string, string> email => name */ private array $subscribers = []; public function subscribe(string $email, string $name): void { // 'Ann@Example.com ' and 'ann@example.com' are two keys $this->subscribers[$email] = $name; } } $newsletter->subscribe($form['name'], $form['email']); // swapped, and nobody notices
<?php final class Newsletter { /** @var array<string, string> normalised email => name */ private array $subscribers = []; public function subscribe(EmailAddress $email, string $name): void { $this->subscribers[$email->value] = $name; } } $newsletter->subscribe($form['name'], EmailAddress::fromString($form['email'])); // PHPStan: Parameter #1 $email expects EmailAddress, string given.