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:
- No identity: No ID field. Equality is based on attribute comparison.
- Immutable: Cannot be changed after creation. If you need a different value, create a new instance.
- Self-validating: Constructor validates invariants. A
Money(-5, 'USD')should throw — negative money is invalid. - 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'