0

Value Object — an object defined by its attributes, not identity

Intermediate5 min read·eng-16-016
interviewcompare

Concept

Value Object — an object defined entirely by its attributes, with no individual identity. Two Value Objects with the same attribute values are considered equal and interchangeable.

Contrast with Entity: An entity is identified by an ID. A Value Object is identified by its values. There's no "the $10 bill" vs "another $10 bill" — all $10 bills are interchangeable (same value).

Properties of Value Objects:

  1. No identity: No ID field. Equality is based on attribute comparison.
  2. Immutable: Cannot be changed after creation. If you need a different value, create a new instance.
  3. Self-validating: Constructor validates invariants. A Money(-5, 'USD') should throw — negative money is invalid.
  4. Replaceable: You replace a Value Object with another; you don't mutate it.

Common Value Objects in PHP:

  • Money(amount: 1000, currency: 'USD') — prices, balances.
  • Email(address: 'alice@example.com') — validated email.
  • DateRange(from: $start, to: $end) — with invariant: $from < $to.
  • Coordinates(lat: 51.5074, lng: -0.1278).
  • Color(r: 255, g: 0, b: 0).
  • PhoneNumber(number: '+15551234567') — validated/formatted.

PHP 8.1 readonly: Perfect for Value Objects. public readonly int $amount — set once in constructor, never changed.

PHP 8.2 readonly class: All properties readonly by default. Ideal for Value Objects.

Eloquent Casts: Cast a column to a Value Object using a custom cast class.

Code Example

php
<?php
// VALUE OBJECT — immutable, no identity, defined by attributes
final class Money
{
    public function __construct(
        public readonly int    $amount,   // in cents
        public readonly string $currency,
    ) {
        if ($amount < 0) throw new \InvalidArgumentException("Amount cannot be negative: {$amount}");
        if (!in_array($currency, ['USD', 'EUR', 'GBP'])) {
            throw new \InvalidArgumentException("Unknown currency: {$currency}");
        }
    }

    // Equality based on VALUES, not reference
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount && $this->currency === $other->currency;
    }

    // Operations return NEW instances (immutable)
    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) throw new \InvalidArgumentException('Currency mismatch');
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function multiply(float $factor): self
    {
        return new self((int) round($this->amount * $factor), $this->currency);
    }

    public function __toString(): string
    {
        return number_format($this->amount / 100, 2) . ' ' . $this->currency;
    }
}

$price   = new Money(1000, 'USD'); // $10.00
$tax     = $price->multiply(0.1);  // $1.00 (new instance)
$total   = $price->add($tax);       // $11.00 (new instance)

$a = new Money(1000, 'USD');
$b = new Money(1000, 'USD');
$a->equals($b); // true — same values = same Value Object (despite different PHP objects)

// PHP 8.2 readonly class
readonly class Email
{
    public readonly string $address;

    public function __construct(string $address)
    {
        if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$address}");
        }
        $this->address = strtolower(trim($address));
    }

    public function equals(self $other): bool { return $this->address === $other->address; }
    public function __toString(): string       { return $this->address; }
}

// Using with Eloquent via custom cast
class MoneyCast implements \Illuminate\Contracts\Database\Eloquent\CastsAttributes
{
    public function get($model, string $key, mixed $value, array $attributes): Money
    {
        return new Money((int) $value, $attributes['currency'] ?? 'USD');
    }

    public function set($model, string $key, mixed $value, array $attributes): array
    {
        return [$key => $value->amount, 'currency' => $value->currency];
    }
}

class Product extends \Illuminate\Database\Eloquent\Model
{
    protected $casts = ['price' => MoneyCast::class]; // DB: integer cents ↔ Money object
}

$product        = Product::find(1);
$product->price; // Money(999, 'USD')
echo $product->price; // '9.99 USD'