DDD Value Objects in PHP
debt(d5/e5/b5/t5)
Closest to 'specialist tool catches it' (d5). PHPStan and Psalm (listed in detection_hints.tools) can identify primitive obsession through custom rules and type analysis, but they don't catch missing value objects by default — you need level 9+ strictness or custom rules. The code_pattern shows 'Email address as raw string across 10 files' which static analysis won't flag as wrong without explicit configuration.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix says to 'create a value object for every domain concept passed as a string/int' — this means creating new classes, updating all call sites across the codebase, and modifying method signatures. When primitives are used in 10+ files (per detection_hints.code_pattern), introducing value objects requires touching all those locations.
Closest to 'persistent productivity tax' (b5). Value objects apply across all contexts (web, cli, queue-worker) and have architectural reach — once you commit to DDD value objects, every new domain concept should follow the pattern. The burden is moderate: it's not defining the system's shape (b9), but it does slow down work streams as developers must create and maintain value object classes for domain concepts rather than passing primitives.
Closest to 'notable trap - documented gotcha most devs eventually learn' (t5). The misconception explicitly states 'Value objects are just DTOs' — many developers conflate these patterns. The common_mistakes list multiple traps: making them mutable, adding identity fields, skipping equality comparison. These are well-documented gotchas that most developers learn after initial exposure to DDD.
Also Known As
TL;DR
Explanation
A Value Object (VO) has no identity — two Money objects with amount=100 and currency='GBP' are equal and interchangeable. VOs are immutable: operations return new instances rather than mutating state. They self-validate in the constructor, throwing on invalid input, so a valid VO instance is always well-formed. PHP 8.1 readonly classes make VOs concise with zero boilerplate. Common PHP VOs: Money (int amount + string currency), Email (validated string), DateRange (two DateTimeImmutable values with ordering constraint), Coordinate (lat/lng with range validation). VOs replace primitive obsession: instead of string $email scattered everywhere, Email $email carries its own validation and behaviour ($email->domain(), $email->isDisposable()). Store in the database as simple columns via Doctrine embeddables or Eloquent casts.
Diagram
flowchart LR
subgraph Primitive Obsession
STR[string email = alice@example.com]
STR -->|passed around| VALID[Validated somewhere?<br/>Who knows?]
STR -->|compared with ==| COMP[Comparison OK]
end
subgraph Value Object
VO[Email value object<br/>validated in constructor]
VO -->|equals method| EQ[Structural equality<br/>not reference equality]
VO -->|immutable| IMM[Cannot be changed<br/>only replaced]
VO -->|self-documenting| DOC[Type system enforces valid email]
end
style VALID fill:#f85149,color:#fff
style VO fill:#238636,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Mutable value objects — they should never have setters; create a new instance for any change.
- Value objects with identity (database ID) — those are entities, not value objects.
- Not implementing equality comparison — two EmailAddress('a@b.com') should be equal.
- Overly generic value objects like StringValue or IntValue — only wrap primitives when domain rules apply.
Code Examples
// Primitive email — validation scattered everywhere:
function sendEmail(string $email): void {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException();
// Same validation repeated in createUser(), updateProfile(), subscribe()...
}
// Value object — validation once, at construction:
class Email {
public function __construct(public readonly string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException();
}
}
readonly class Money {
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) throw new InvalidArgumentException('Amount cannot be negative');
}
public function add(Money $other): self {
if ($this->currency !== $other->currency) throw new CurrencyMismatch();
return new self($this->amount + $other->amount, $this->currency);
}
}