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
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:
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 baseValue 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(), // stringReconstruct in fromRow():
Email::fromString($row['email']),
Money::of((int) $row['amount'], $row['currency']),