Value Object
debt(d7/e5/b5/t5)
Closest to 'only careful code review or runtime testing' (d7). The detection_hints list phpstan and psalm but note automated=no — these tools can flag primitive obsession heuristically but cannot reliably detect missing value objects or mutable VOs without custom rules. The code pattern described (email as raw string passed through 5 functions, Money as float causing rounding errors) is largely invisible to static analysis and surfaces through code review or runtime bugs like float rounding errors.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix describes creating an immutable class with readonly properties and constructor validation, but the common_mistakes (mutable VOs, missing equality, VOs holding entity references) suggest the real remediation involves hunting down all call sites where primitives are passed, replacing them with value object types, and updating comparison logic. This typically touches multiple files across a feature or domain layer, not a single-line swap.
Closest to 'persistent productivity tax' (b5). The applies_to covers web, cli, and queue-worker contexts — broad reach. Once primitive obsession is entrenched (email as string everywhere, money as float everywhere), correcting it imposes a persistent tax on new features and bug fixes. However, once value objects are properly introduced they actually reduce burden over time, so this is not quite a b7 gravitational pull — it's a significant but correctable tax during the remediation period.
Closest to 'notable trap (a documented gotcha most devs eventually learn)' (t5). The misconception field explicitly states the canonical trap: developers conflate value objects with DTOs, treating them as simple data carriers without behaviour or invariants. The common_mistakes reinforce this — mutable VOs, wrong equality semantics (== vs ===), and VOs holding entity references are all traps a competent developer will fall into without DDD background. This is a well-documented gotcha but not one that contradicts a closely analogous concept, keeping it at t5.
Also Known As
TL;DR
Explanation
Value Objects (Domain-Driven Design) encapsulate primitive values with business meaning and enforce constraints at construction. A Money(amount: 100, currency: 'GBP') value object guarantees valid currency, prevents negative amounts, and makes equality meaningful. They are immutable (operations return new instances), have no identity (two identical Value Objects are the same thing), and are self-validating. They replace primitive obsession, make invalid state unrepresentable, and greatly improve domain model expressiveness.
Common Misconception
Why It Matters
Common Mistakes
- Mutable value objects — a Money object whose amount can be changed after construction breaks equality semantics.
- Not overriding equality methods — PHP's == compares object properties by default but === requires same instance.
- Value objects that hold references to entities — they become contextually dependent and lose their value semantics.
- Using value objects for things that have identity — a User is an entity, not a value object, even if two users have the same name.
Avoid When
- The concept has identity that matters — two users with the same name are not the same user; use an entity instead.
- The object is mutable — value objects must be immutable; mutable value objects cause subtle aliasing bugs.
- Wrapping every primitive in a value object — over-engineering that adds noise without meaningful domain modelling.
When To Use
- Domain concepts defined entirely by their attributes — Money, EmailAddress, Coordinates, DateRange.
- Enforcing invariants at construction time — an EmailAddress object is always valid; a plain string is not.
- Replacing primitive obsession — replacing string $email with EmailAddress $email makes intent clear.
- Immutable data that benefits from equality-by-value semantics rather than identity.
Code Examples
// Primitive obsession — no validation, no behaviour
function transfer(int $amount, string $currency): void {}
readonly class Money {
public function __construct(
public int $amount, // in minor units (pence/cents)
public string $currency, // ISO 4217
) {
if ($amount < 0) throw new \InvalidArgumentException('Amount cannot be negative');
if (strlen($currency) !== 3) throw new \InvalidArgumentException('Invalid currency code');
}
public function add(self $other): self {
if ($this->currency !== $other->currency) throw new CurrencyMismatch();
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(self $other): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}