TL;DR
π‘ Value Objects make impossible states impossible
β οΈ "Stringly typed" code causes 60% of production bugs
β
 One VO prevents hundreds of validation checks
π₯ Eric Evans' rule: "Make meaningful, not just wrap primitives"  
βββββββββββββββββββββββββββββ
π± The Problem: Primitive Obsession Hell
Last year I reviewed code that crashed production. The bug? Someone passed "usd" instead of "USD" to a payment gateway.
The killer line:
$order->setTotal("100");      // String? Float? With currency?
$order->setCurrency("usd");   // Lowercase? Uppercase? Valid?
This happens everywhere:
// What could go wrong? π₯
function createUser(string $email, string $phone, string $price) {
    // Is email validated? No idea.
    // Is phone formatted? Who knows.
    // Is price "100" or "100.00" or "$100"? Good luck.
}
β οΈ Common Disasters:
- 
calculateDiscount(50, 100)β which is price, which is percentage? - 
"2024-03-15"vs"15-03-2024"β format confusion - 
nullfor email β is it allowed or a bug? - Validating email in 17 different places
 
π¬ "Primitive Obsession is the root of all evil in enterprise code."
β Martin Fowler
βββββββββββββββββββββββββββββ
π― The Solution: Value Objects to the Rescue
What's a Value Object?
A Value Object is an immutable object compared by value, not identity.
Key traits:
- β Immutable (can't change after creation)
 - β Self-validating (invalid VOs can't exist)
 - β Compared by value (two identical emails are equal)
 - β
 No identity (no 
getId()method) 
π₯ Real Example: Email
β Before (Primitive Hell):
class User 
{
    private string $email; // Any string works π±
    public function setEmail(string $email): void 
    {
        // Validation? Maybe. Maybe not.
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \Exception('Invalid email');
        }
        $this->email = strtolower($email);
    }
}
// Now validate EVERYWHERE you use email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { /* ... */ }
β
 After (Value Object):
final class Email 
{
    private string $value;
    private function __construct(string $value) 
    {
        $normalized = strtolower(trim($value));
        if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException($value);
        }
        $this->value = $normalized;
    }
    public static function fromString(string $value): self 
    {
        return new self($value);
    }
    public function toString(): string 
    {
        return $this->value;
    }
    public function equals(Email $other): bool 
    {
        return $this->value === $other->value;
    }
}
Usage:
class User 
{
    private Email $email; // β Invalid email can't exist!
    public function __construct(Email $email) 
    {
        $this->email = $email; // Already validated β
    }
}
// Create user
$user = new User(Email::fromString('TEST@example.com'));
// Email automatically normalized to "test@example.com"
// Try invalid email
$user = new User(Email::fromString('not-an-email')); // β Throws exception
π‘ Magic: Validate once at creation. Everywhere else, just use it.
βββββββββββββββββββββββββββββ
ποΈ More Real-World Examples
π° Money Value Object
β Before:
// What's the currency? Is it cents or dollars?
$order->setTotal(10000);
$order->setCurrency('USD');
// Bugs waiting to happen:
$total = $order->getTotal() + $discount; // Mixed currencies? π±
β
 After:
final class Money 
{
    private function __construct(
        private int $amount,      // Always in cents
        private string $currency
    ) {
        if ($amount < 0) {
            throw new InvalidMoneyException('Amount cannot be negative');
        }
        if (!in_array($currency, ['USD', 'EUR', 'GBP'])) {
            throw new InvalidCurrencyException($currency);
        }
    }
    public static function usd(int $cents): self 
    {
        return new self($cents, 'USD');
    }
    public function add(Money $other): self 
    {
        if ($this->currency !== $other->currency) {
            throw new CurrencyMismatchException();
        }
        return new self(
            $this->amount + $other->amount,
            $this->currency
        );
    }
    public function multiply(Percentage $percentage): self 
    {
        return new self(
            (int) ($this->amount * $percentage->toDecimal()),
            $this->currency
        );
    }
}
// Usage
$price = Money::usd(10000);        // $100.00
$discount = Money::usd(1500);      // $15.00
$total = $price->add($discount);   // Type-safe!
// This won't compile:
$total = $price->add("15.00");     // β Type error! β
π Address Value Object
final class Address 
{
    private function __construct(
        private string $street,
        private string $city,
        private string $zipCode,
        private string $country
    ) {
        if (strlen($zipCode) < 4 || strlen($zipCode) > 10) {
            throw new InvalidZipCodeException($zipCode);
        }
    }
    public static function create(
        string $street,
        string $city,
        string $zipCode,
        string $country
    ): self {
        return new self($street, $city, $zipCode, $country);
    }
    public function isSameCity(Address $other): bool 
    {
        return $this->city === $other->city 
            && $this->country === $other->country;
    }
}
βββββββββββββββββββββββββββββ
π Eric Evans' Rule: When to Create a VO?
Not every field needs a VO. Here's the decision tree:
β CREATE a Value Object if:
| Condition | Example | 
|---|---|
| Has business rules | 
Email (format validation) | 
| Has behavior | 
Money::add(), DateRange::overlaps()
 | 
| Appears in multiple entities | 
Money in Order, Invoice, Payment | 
| Has business meaning | 
Percentage, PhoneNumber
 | 
β DON'T create if:
| Condition | Example | 
|---|---|
| Simple primitive | 
quantity: int is fine | 
| One-time use | sortOrder: int | 
| Framework type works | \DateTimeImmutable | 
| Just a label | 
status: string (unless has behavior) | 
π Real Entity Example
final class Order 
{
    private OrderId $id;              // β
 VO: unique identifier
    private Email $customerEmail;     // β
 VO: validation + behavior
    private Money $total;             // β
 VO: calculations
    private Address $shippingAddress; // β
 VO: complex value
    private \DateTimeImmutable $createdAt; // β Framework type is enough
    private int $itemCount;           // β Simple primitive
    private OrderStatus $status;      // β
 VO: if has behavior (state machine)
}
π¬ "Value Objects are not about wrapping primitives. They're about expressing business concepts."
β Eric Evans, Domain-Driven Design
βββββββββββββββββββββββββββββ
π₯ Before/After: Real Production Code
Scenario: E-commerce Discount System
β Before (Nightmare):
class Order 
{
    public function applyDiscount(float $percent): void 
    {
        // What if percent is 150? Or -50? π±
        $discount = $this->total * ($percent / 100);
        $this->total -= $discount;
        // Can total go negative? Who knows! π±
    }
}
// Usage (bugs everywhere)
$order->applyDiscount(10);    // 10%? 0.10? Who knows!
$order->applyDiscount(200);   // Oops, negative total
$order->applyDiscount(-50);   // Customer gets money? π₯
β
 After (Rock Solid):
final class Percentage 
{
    private function __construct(private int $value) 
    {
        if ($value < 0 || $value > 100) {
            throw new InvalidPercentageException($value);
        }
    }
    public static function fromInt(int $value): self 
    {
        return new self($value);
    }
    public function toDecimal(): float 
    {
        return $this->value / 100;
    }
}
final class Money 
{
    public function applyDiscount(Percentage $discount): self 
    {
        $discountAmount = (int) ($this->amount * $discount->toDecimal());
        return new self(
            max(0, $this->amount - $discountAmount), // Never negative
            $this->currency
        );
    }
}
// Usage (impossible to misuse)
$discounted = $price->applyDiscount(Percentage::fromInt(10)); // Clear!
$invalid = Percentage::fromInt(150); // β Throws exception β
βββββββββββββββββββββββββββββ
π Value Objects Cheat Sheet
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β  Primitive β Value Object Mapping               β
βββββββββββββββββββββββββββββββββββββββββββββββββββ€
β  string email     β Email                       β
β  string phone     β PhoneNumber                 β
β  float price      β Money                       β
β  int percent      β Percentage                  β
β  string address   β Address                     β
β  string uuid      β EntityId (OrderId, UserId)  β
β  array coords     β Coordinates                 β
β  string date      β DateRange, Period           β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Folder Structure:
Domain/
βββ ValueObject/
β   βββ Email.php
β   βββ Money.php
β   βββ Percentage.php
β   βββ Address.php
β   βββ PhoneNumber.php
βββ Entity/
    βββ Order.php
βββββββββββββββββββββββββββββ
π Start Small: Your First Value Object
Today: Pick your most-validated string (probably email)
Tomorrow: Create Email Value Object
This week: Replace all string $email with Email
Next week: Add Money, Percentage  
Pro tip: New code only. Don't refactor everything at once.
βββββββββββββββββββββββββββββ
π¬ Discussion Time
What's the most annoying primitive type in your codebase? Is it string $price, array $address, or something else?
Drop your worst "primitive obsession" horror story in the comments! π
Bonus: What Value Object would save you the most debugging time?
    
Top comments (0)