Value objects are one of the most underused tactical patterns in Domain-Driven Design. Most teams jump straight to entities and aggregates, but value objects are where the real expressiveness lives.
What Makes a Value Object?
A value object is defined by its attributes rather than by an identity. Two value objects with the same attributes are considered equal — there's no need for an id field.
final readonly class Money
{
public function __construct(
public int $amount,
public Currency $currency,
) {
}
public function add(self $other): self
{
if (!$this->currency->equals($other->currency)) {
throw new CurrencyMismatch($this->currency, $other->currency);
}
return new self($this->amount + $other->amount, $this->currency);
}
}
Why They Matter
Value objects push business rules into the type system itself. Instead of passing around primitive strings and integers — and scattering validation logic across your services — you encode constraints directly in the constructor.
Self-Validating
A EmailAddress value object that validates format on construction means you never have to wonder whether a string is actually a valid email. The type tells you.
Immutable by Default
PHP 8.2's readonly classes make value objects a natural fit. Once created, they can't be corrupted. Every operation returns a new instance.
Rich Behavior
Value objects aren't just data bags. They can carry domain behavior: Money::add(), DateRange::overlaps(), Address::format(). This is where your domain language comes alive.
Practical Advice
Start small. Look at your codebase for primitive obsession — places where you're passing string $email, int $cents, string $currency. Each of those is a value object waiting to happen.