Vortos
Domain

Value Objects

ValueObject — equality by value, immutability, validation in factory methods, and toString for logging.

Value Objects

Value objects have no identity — two Email('alice@example.com') instances are equal because their values are equal, not because they are the same object. They are always immutable.

ValueObject Base Class

abstract readonly class ValueObject
{
    abstract public function equals(ValueObject $other): bool;
    abstract public function __toString(): string;
}

ValueObject is abstract readonly — subclasses that extend it must also be readonly. Simple value objects can skip the base class entirely and just implement \Stringable (see the Email example below).

Define a Value Object

src/User/Domain/User/ValueObjects/Email.php
final class Email implements \Stringable
{
    private readonly string $value;

    public function __construct(string $value)
    {
        $normalized = strtolower(trim($value));

        if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email address: {$value}");
        }

        $this->value = $normalized;
    }

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

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}

For simple value objects, the lightweight pattern above works well: a public constructor that validates in place, a readonly backing field, and \Stringable. The framework also ships an abstract readonly class ValueObject base if you want a shared equals() contract enforced at the type level:

src/Payment/Domain/Payment/ValueObjects/Money.php
final readonly class Money extends ValueObject
{
    private function __construct(
        private int $amount,   // in minor units (cents)
        private string $currency,
    ) {}

    public static function of(int $amount, string $currency): self
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative');
        }

        return new self($amount, strtoupper($currency));
    }

    public function equals(ValueObject $other): bool
    {
        return $other instanceof self
            && $this->amount === $other->amount
            && $this->currency === $other->currency;
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException('Cannot add different currencies');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function __toString(): string
    {
        return sprintf('%s %.2f', $this->currency, $this->amount / 100);
    }
}

Key Rules

Validate on construction. Either validate in a public constructor (simple VOs) or in a private constructor called from a public factory method (VOs using the ValueObject base). Invalid value objects must never be creatable.

Return new instances for transformations. Never mutate:

// CORRECT — new instance
public function toLowerCase(): self
{
    return new self(strtolower($this->value));
}

// WRONG — mutation (impossible with readonly, but the principle applies)
public function toLowerCase(): void
{
    $this->value = strtolower($this->value); // readonly prevents this
}

equals() checks instanceof first. A Name is never equal to an Email even if their string values match:

public function equals(ValueObject $other): bool
{
    return $other instanceof self && $this->value === $other->value;
}

Common Value Objects

final class Email implements \Stringable { ... }          // lightweight pattern
final class PhoneNumber implements \Stringable { ... }    // lightweight pattern
final readonly class Money extends ValueObject { ... }    // ValueObject base
final readonly class Address extends ValueObject { ... }  // ValueObject base

Value Objects in Database

Store value objects as their primitive representation. In DbalMapper::toRow():

'email' => (string) $user->getEmail(),  // __toString()
'amount' => $order->getTotal()->getAmount(),  // int
'currency' => $order->getTotal()->getCurrency(),  // string

Reconstruct in fromRow():

Email::fromString($row['email']),
Money::of((int) $row['amount'], $row['currency']),

On this page