0

Value Objects — immutability, equality by value

Intermediate5 min read·php-08-006
interviewsolid

Concept

A Value Object is a domain modeling pattern where an object's identity is determined entirely by its value, not by a unique identifier or database row. Two instances of Email holding the string "alice@example.com" are considered equal — it does not matter which instance you use. This contrasts with Entities, which have identity through a unique ID: two User objects with the same email but different IDs are not the same user.

The defining characteristics of a well-formed Value Object are immutability and value equality. Immutability means you cannot change a Value Object after construction — operations that would "change" it return a new instance instead. Value equality means comparison is based on the internal data, not object identity (=== in PHP compares by reference, which is wrong for Value Objects; you need an equals() method or careful comparison).

Value Objects eliminate entire classes of bugs. Consider storing a raw string for an email address — nothing prevents you from setting it to "not_an_email", passing it to a function that expects a URL, or making a typo that swaps two strings. Wrapping it in an Email Value Object means the string is validated once at construction, the type system ensures it cannot be used where a Url is expected, and there is a single authoritative definition of what constitutes a valid email address.

In PHP 8.2+, readonly class is the canonical implementation mechanism for Value Objects. Before 8.2, you achieved immutability by making all properties private with no setters and providing no public mutation methods. Both approaches work; readonly class enforces immutability at the engine level, preventing bugs even in the class's own methods.

Laravel's Eloquent casts system supports Value Object casts via AsValueObject (or a custom CastsAttributes implementation). Libraries like spatie/laravel-data and brick/money implement full Value Object hierarchies. The Money pattern (amount + currency) is the textbook example: $total = $price->add($shipping) returns a new Money rather than mutating $price, making the operation side-effect-free and composable.

Code Example

php
<?php
declare(strict_types=1);

// ── Email Value Object ─────────────────────────────────────────────────────

readonly class Email
{
    public string $local;
    public string $domain;

    public function __construct(public readonly string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$value}");
        }
        [$this->local, $this->domain] = explode('@', $value, 2);
    }

    public function equals(self $other): bool
    {
        return strtolower($this->value) === strtolower($other->value);
    }

    public function withDomain(string $domain): self
    {
        return new self("{$this->local}@{$domain}");
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

// ── Coordinates Value Object ───────────────────────────────────────────────

readonly class Coordinates
{
    public function __construct(
        public float $latitude,
        public float $longitude,
    ) {
        if ($latitude < -90.0 || $latitude > 90.0) {
            throw new \RangeException("Latitude must be between -90 and 90");
        }
        if ($longitude < -180.0 || $longitude > 180.0) {
            throw new \RangeException("Longitude must be between -180 and 180");
        }
    }

    public function distanceTo(Coordinates $other): float
    {
        // Haversine formula — distance in kilometres
        $earthRadius = 6371.0;
        $latDiff  = deg2rad($other->latitude  - $this->latitude);
        $lonDiff  = deg2rad($other->longitude - $this->longitude);
        $a = sin($latDiff / 2) ** 2
            + cos(deg2rad($this->latitude)) * cos(deg2rad($other->latitude))
            * sin($lonDiff / 2) ** 2;
        return $earthRadius * 2 * asin(sqrt($a));
    }

    public function equals(self $other): bool
    {
        return $this->latitude === $other->latitude
            && $this->longitude === $other->longitude;
    }
}

// ── Usage ──────────────────────────────────────────────────────────────────

$email1 = new Email('alice@example.com');
$email2 = new Email('alice@example.com');
$email3 = new Email('bob@example.com');

var_dump($email1->equals($email2)); // true  — same value
var_dump($email1->equals($email3)); // false — different value
var_dump($email1 === $email2);      // false — different instances (not what we want!)
// Always use equals(), not ===, for Value Objects

$paris  = new Coordinates(48.8566, 2.3522);
$london = new Coordinates(51.5074, -0.1278);
$km     = $paris->distanceTo($london);
echo round($km, 1) . " km\n"; // ~342 km

// Value Objects compose well
$companyEmail = $email1->withDomain('newcompany.com');
echo $companyEmail; // alice@newcompany.com
echo $email1;       // alice@example.com — unchanged

Interview Q&A

Q: How do you store a Value Object in a database with Eloquent, and what are the trade-offs?

The standard approach is a custom cast implementing CastsAttributes. The set() method serializes the Value Object to a scalar (or JSON for compound objects); get() deserializes the raw column value back into the Value Object. For compound objects like Coordinates you can either serialize to JSON in one column or spread across multiple columns. A compound cast returning multiple column values is supported via CastsAttributes::set() returning an array. The trade-off: querying by Value Object fields (e.g., finding all users near a coordinate) requires SQL against the underlying columns, not PHP-level comparison. You may need raw whereRaw() calls or duplicate a denormalized column for indexing purposes.


Q: What is the difference between a Value Object and a DTO, and when do you use each?

A Value Object models a domain concept with business rules and invariants enforced at construction (e.g., Email validates the format, Money ensures non-negative amounts). Its equality is by value and it is typically immutable. A DTO (Data Transfer Object) is a plain data carrier with no behaviour and no business rules — it exists to ferry data between layers (controller → service, service → repository) without coupling those layers to each other's types. DTOs are typically mutable (or readonly for convenience) but do not enforce domain invariants. The rule of thumb: if the object has a domain concept and business rules, it is a Value Object; if it is just packaging data for transport, it is a DTO.


Q: Why is === the wrong equality operator for Value Objects in PHP, and how do you handle this in a collection?

=== for objects checks referential identity — whether two variables point to the exact same instance in memory. Two separately constructed Email('alice@example.com') instances will always be ===-inequal regardless of content. This means you cannot use PHP's in_array($email, $collection, strict: true) or array_unique() correctly with Value Objects. Solutions: (1) implement __toString() and use string-based collection operations; (2) define a canonical id() or key() method that returns a string/int usable as an array key; (3) use libraries like Ds\Set or SplObjectStorage with a custom comparator; (4) in PHP, override __toString() and compare via (string)$a === (string)$b. There is no built-in Equatable interface in PHP (unlike Java's equals()), so the convention is always an explicit equals(self $other): bool method.