levelmax World 5 Explicit Design
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 5.1 ~14 min Target: PHPStan max

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

Before
<?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
After
<?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.

Quick check

1. subscribe(string $email, string $name) is called as subscribe($name, $email). What reports it?
2. EmailAddress::fromString('ann@example.com') === EmailAddress::fromString('ann@example.com') is…
3. Where should trim() and strtolower() happen?

CHALLENGE

Ann is subscribed to the newsletter twice, as ann@example.com and Ann@Example.com , because an email address is just a string. The signup form also has a bug that no string type can catch.

Write EmailAddress: a value object with a private constructor, a public value, a fromString() that trims, lowercases and refuses anything that isn't an address with an InvalidArgumentException, and an equals(). Then make Newsletter take EmailAddress instead of string, and let PHPStan show you what's wrong with Signup.

REFERENCE
filter_var($email, FILTER_VALIDATE_EMAIL); // the address, or false

A property promoted in a private function __construct(public string $value) is still readable from outside. Only the constructor is private.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.