← CodeClarityLab Home
Browse by Category
+ added · updated 7d
← Back to glossary

DDD Value Objects in PHP

architecture Intermediate
debt(d5/e5/b5/t5)
d5 Detectability Operational debt — how invisible misuse is to your safety net

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.

e5 Effort Remediation debt — work required to fix once spotted

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.

b5 Burden Structural debt — long-term weight of choosing wrong

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.

t5 Trap Cognitive debt — how counter-intuitive correct behaviour is

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.

About DEBT scoring →

Also Known As

value object DDD VO pattern immutable domain value

TL;DR

Immutable objects defined by their attributes rather than identity — Money, Email, Coordinate — that encapsulate validation and domain behaviour.

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

Value objects are just DTOs. DTOs carry data between layers with no validation or behaviour. Value objects enforce domain invariants on construction, are compared by value not identity, and can carry domain behaviour — an Email value object that validates format and provides a domain() method is not a DTO.

Why It Matters

Value objects are immutable, equality-by-value types representing domain concepts — wrapping primitives in value objects centralises validation and makes domain concepts first-class in the type system.

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

✗ Vulnerable
// 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();
    }
}
✓ Fixed
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);
    }
}

Added 15 Mar 2026
Edited 22 Mar 2026
Views 58
Rate this term
No ratings yet
🤖 AI Guestbook educational data only
| |
Last 30 days
1 ping T 1 ping F 0 pings S 0 pings S 1 ping M 0 pings T 0 pings W 0 pings T 2 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 2 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 1 ping T 1 ping F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F
No pings yet today
No pings yesterday
Perplexity 26 Amazonbot 9 Google 8 Unknown AI 3 Majestic 2 Ahrefs 2 ChatGPT 1
crawler 49 crawler_json 1 pre-tracking 1
DEV INTEL Tools & Severity
🟡 Medium ⚙ Fix effort: Medium
⚡ Quick Fix
Create a value object for every domain concept passed as a string/int — Email, Money, OrderId, DateRange — validate in the constructor so invalid instances are impossible
📦 Applies To
any web cli queue-worker
🔗 Prerequisites
🔍 Detection Hints
Email address as raw string across 10 files with repeated regex validation; Money as float causing rounding errors; passing user_id as plain int to wrong service
Auto-detectable: ✗ No phpstan psalm
⚠ Related Problems
🤖 AI Agent
Confidence: Low False Positives: High ✗ Manual fix Fix: Medium Context: Class Tests: Update

✓ schema.org compliant