Back to code
PHPDDD

Value objects over primitive obsession

A string $email can hold "not-an-email" all the way to your database. The type system isn't lying to you - it's just not telling you the truth you need.

Bad - the invariant lives nowhere

final class RegisterUser
{
    public function __construct(
        private readonly UserRepository $users,
    ) {}

    public function handle(string $email, string $password): void
    {
        if (!str_contains($email, '@')) {
            throw new InvalidArgumentException('Invalid email');
        }

        $this->users->save(new User($email, $password));
    }
}

The check is correct today. It also has to be remembered, and re-implemented, everywhere else an email address enters the system - a second use case, an admin import script, a console command. Miss one and the invariant is gone.

Good - the invariant is the type

final class EmailAddress
{
    private function __construct(private readonly string $value) {}

    public static function fromString(string $value): self
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailAddress($value);
        }

        return new self($value);
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

final class RegisterUser
{
    public function __construct(
        private readonly UserRepository $users,
    ) {}

    public function handle(EmailAddress $email, Password $password): void
    {
        $this->users->save(new User($email, $password));
    }
}

EmailAddress can't exist in an invalid state - construction is validation. Every call site downstream, including ones written six months from now by someone who hasn't read this file, gets the guarantee for free.

The tradeoff: more classes, more ceremony for what looks like a string. Worth it exactly where the invariant is business-critical, not for every field on every form.