Equality, validation, and the boundaries of clean object models
Anyone who treats Value Objects as nothing more than fancier data classes gives up their real benefit: a Value Object encapsulates equality, validity, and immutability in exactly one place in the code, instead of scattering those rules across services, controllers, and forms. This article shows, in plain, framework-independent PHP 8.4, how to use equals() instead of naive comparison, how to enforce invariants inside the constructor, and how readonly properties deliver true immutability, all without losing the balance between objects that are too small and objects that are too large.
Table of contents
- 1. What a Value Object is, and how it differs from an Entity
- 2. Modeling equality correctly: comparing values instead of identity
- 3. Enforcing immutability consistently: readonly and wither methods
- 4. Validation at construction time: guaranteeing invariants
- 5. Practical example Money: amount and currency as a Value Object
- 6. Practical example EmailAddress: validation, normalization, comparability
- 7. Boundaries of Value Objects: when an object is really an Entity
- 8. Value Objects and serialization: JsonSerializable, arrays, collections
- 9. Entity and Value Object side by side
- 10. Summary
- 11. FAQ
1. What a Value Object is, and how it differs from an Entity
A Value Object is an object that is defined exclusively by its values, not by an ongoing identity. Two instances with the same attributes are interchangeable and equal, regardless of whether they are two separate objects in memory. An Entity, by contrast, carries an identity, usually an ID, that stays stable throughout its entire lifetime, even if every single attribute changes. A customer stays the same customer, even if name, address, and email address change, as long as the customer ID stays identical.
Typical representatives of this concept in a domain are amounts of money, email addresses, date ranges, coordinates, and percentages. Typical Entities are Customer, Order, and Product, things that are tracked over time and whose identity matters more than the current state of their attributes. The distinction sounds academic, but it has concrete consequences for the code: such an object needs no database ID, no constructor with optional parameters, and no state that changes after creation.
Anyone who ignores this distinction often ends up with Primitive Obsession: amounts of money as float, email addresses as string, date ranges as two loose DateTime parameters. Every one of those spots in the code has to reimplement validation, formatting, and comparison logic from scratch. A cleanly cut Value Object bundles all of that in a single place and makes invalid states impossible via the constructor.
2. Modeling equality correctly: comparing values instead of identity
Entities are compared by their ID: two orders with the same ID are the same order, regardless of the rest of their state. With a Value Object it is the other way around: two instances are equal if, and only if, all relevant properties match. PHP does offer a comparison via the == operator, which recursively checks class and public properties on objects, but for this kind of type an explicit equals() method is the better choice. It documents intent in the code, stays stable once private properties are added, and allows for special cases such as tolerance comparisons on floating-point numbers.
The following example shows a minimal class with an explicit equality method:
declare(strict_types=1);
final class Point
{
public function __construct(
public readonly float $x,
public readonly float $y,
) {
}
// Value equality: compare state, not object identity
public function equals(self $other): bool
{
return $this->x === $other->x
&& $this->y === $other->y;
}
}
$a = new Point(1.5, 2.5);
$b = new Point(1.5, 2.5);
var_dump($a == $b); // true (PHP compares public properties)
var_dump($a === $b); // false (two distinct object instances)
var_dump($a->equals($b)); // true (explicit, documents the intent)
The difference between == and === is particularly treacherous with Value Objects: === checks identity and, for two separately created instances, almost always returns false, even when the values are identical. == often works correctly by accident, but fails as soon as such an object itself contains other objects as properties whose == comparison does not produce the desired behavior. A dedicated equals() method makes the comparison logic explicit, testable, and independent of PHP's built-in behavior, which can vary across versions and object graphs.
3. Enforcing immutability consistently: readonly and wither methods
Immutability is not an academic nicety, it is the property that makes equality on Value Objects reliable in the first place. If an object's state can change after creation, every comparison, every use as an array key, and every shared reference across multiple services becomes a potential source of bugs. Such an object, once created, should carry the same state for its entire lifetime. PHP 8.4 supports this directly through readonly properties combined with constructor property promotion, with no extra libraries required.
Changes to an immutable object of this kind go through so-called wither methods: instead of a setter that mutates the existing state, a method such as withEnd() returns a completely new instance with the changed value. The caller decides whether to keep using the original or adopt the new object; both instances remain independently valid and consistent.
declare(strict_types=1);
final class DateRange
{
public function __construct(
public readonly \DateTimeImmutable $start,
public readonly \DateTimeImmutable $end,
) {
if ($start > $end) {
throw new \InvalidArgumentException('Start must be before end.');
}
}
// "Wither" method: returns a new instance instead of mutating this one
public function withEnd(\DateTimeImmutable $end): self
{
return new self($this->start, $end);
}
public function days(): int
{
return (int) $this->start->diff($this->end)->days;
}
}
$range = new DateRange(
new \DateTimeImmutable('2026-01-01'),
new \DateTimeImmutable('2026-01-10'),
);
$extended = $range->withEnd(new \DateTimeImmutable('2026-02-01'));
// $range stays untouched, $extended is a new, independent instance
One trap with readonly properties: the keyword only prevents reassigning the property itself, not changing a mutable object contained within it. Anyone who accidentally stores \DateTime instead of \DateTimeImmutable in a Value Object opens a back door for outside mutation that undermines the entire immutability guarantee. For this kind of type the rule is therefore: every property must be either a scalar, an already immutable object, or another self-contained, immutable object.
4. Validation at construction time: guaranteeing invariants
The most effective place to validate a Value Object is the constructor itself, not a downstream validator service. If the constructor checks every invariant and throws an exception on violation, an invalid object of this kind can never come into existence in the first place. There is no intermediate state in which an object exists but is invalid because validation is only called later. This principle is often called "always-valid domain objects": as soon as the instance exists, it is valid by definition.
The following example demonstrates a percentage value that enforces its own bounds:
declare(strict_types=1);
final class Percentage
{
private const float MIN = 0.0;
private const float MAX = 100.0;
public function __construct(
public readonly float $value,
) {
if ($value < self::MIN || $value > self::MAX) {
throw new \InvalidArgumentException(
sprintf('Percentage must be between %.1f and %.1f, got %.2f.', self::MIN, self::MAX, $value)
);
}
}
}
// Throws immediately; an invalid Percentage instance can never exist
new Percentage(142.0);
For cases where an invalid value should not be a hard error but rather an expected alternative in the control flow, a static factory method such as tryFrom() is a good fit: it returns null instead of throwing an exception. The constructor itself stays strict either way, while the caller can decide whether it expects a hard failure or a soft return value. What matters is that the validation logic stays bundled in a single place inside the Value Object in both cases, instead of being duplicated across multiple spots in the calling code.
5. Practical example Money: amount and currency as a Value Object
Amounts of money are the classic textbook example of a Value Object, because naive implementations using float almost always lead to rounding errors. A robust Money class stores the amount as an integer in the smallest unit, cents instead of euros, and carries the currency as a second, equally important attribute. Only the combination of amount and currency makes sense; an amount without a currency is incomplete and therefore a violation of the object's invariant.
Arithmetic operations on a Money object must never change the original amount; they always return a new instance instead. The following example shows a complete implementation with an equality check, addition and subtraction methods, and a guard against mixing different currencies:
declare(strict_types=1);
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException(sprintf('Invalid currency code "%s".', $currency));
}
}
// Value equality: same amount and same currency, nothing else matters
public function equals(self $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
$newAmount = $this->amountInCents - $other->amountInCents;
if ($newAmount < 0) {
throw new \InvalidArgumentException('Resulting amount cannot be negative.');
}
return new self($newAmount, $this->currency);
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->amountInCents > $other->amountInCents;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Cannot operate on Money with different currencies.');
}
}
public function __toString(): string
{
return sprintf('%.2f %s', $this->amountInCents / 100, $this->currency);
}
}
$price = new Money(1999, 'EUR');
$discount = new Money(200, 'EUR');
$final = $price->subtract($discount);
echo $final; // 17.99 EUR ($price itself remains completely unchanged)
This implementation of a Value Object for amounts of money can be used anywhere a shopping cart, an invoice, or a price calculation previously relied on loose floats and strings for currency codes. The guard against mixing currencies in assertSameCurrency() prevents errors that would happen silently and unnoticed with plain float additions, such as accidentally adding euros and dollars into a plausible-looking but factually wrong total.
6. Practical example EmailAddress: validation, normalization, comparability
Treating an email address as a plain string almost inevitably leads to validation and normalization being duplicated in multiple places in the code, with the risk that one of those places gets forgotten. An EmailAddress class bundles both: the constructor validates the format via filter_var() and simultaneously normalizes case and surrounding whitespace, so that two addresses written differently but referring to the same mailbox are recognized as equal.
Normalization is especially important for the equality check on this Value Object: without it, User@Example.com and user@example.com would be considered different, even though both address the same mailbox. The following example implements validation, normalization, and an equals() method in a single, compact class:
declare(strict_types=1);
final class EmailAddress implements \JsonSerializable
{
private readonly string $value;
public function __construct(string $value)
{
// Normalize before validating: trim whitespace, lowercase the address
$normalized = strtolower(trim($value));
if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(sprintf('"%s" is not a valid email address.', $value));
}
$this->value = $normalized;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function jsonSerialize(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
}
$a = new EmailAddress(' User@Example.com ');
$b = new EmailAddress('user@example.com');
var_dump($a->equals($b)); // true, normalization makes both representations equal
echo json_encode(['contact' => $a]); // {"contact":"user@example.com"}
It matters where the line is drawn for what belongs in the constructor of a Value Object and what does not: syntactic validation, checking for a well-formed email format, absolutely belongs in the constructor, because it is an invariant of the value itself. Business-level checks, such as whether a domain actually exists, whether an MX record is set, or whether the address is already registered in the database, belong instead in a separate application service, because they require external dependencies such as DNS resolution or database access that a plain class of this kind should never own itself.
7. Boundaries of Value Objects: when an object is really an Entity
Value Objects should be kept deliberately small. As soon as an object starts carrying a dozen fields, several independent behaviors, and relationships to other objects, it loses the clarity that makes a well-cut instance of this kind valuable. A typical warning sign is when a supposed value type gets methods that really belong to an application service, such as sending a notification or writing to a database. Responsibilities like that do not belong in an object whose only job is encapsulating values and invariants.
The decisive question for drawing this line is: do I care which concrete instance I am holding, or do I only care about the values it carries? For an address that is a plain attribute of a customer, this modeling approach is usually enough. But once the same address has to be tracked over time, say because a history of moves is stored, or several customers reference the same shipping address and a change in one place should propagate everywhere, the object needs its own identity and becomes an Entity.
This boundary is not a one-time decision; it can shift as requirements grow. A well-cut Value Object can be evolved into an Entity relatively easily when needed, simply by adding an ID and a repository. The reverse path, breaking an overloaded Entity apart into smaller, focused objects later on, is considerably more work in practice, which is why making the conscious decision at the start of a feature pays off.
8. Value Objects and serialization: JsonSerializable, arrays, collections
For output in an API or for logging, it makes sense for a Value Object to know how to turn itself into a simple format. The JsonSerializable interface, with its jsonSerialize() method, takes over exactly that job for json_encode(), as shown in the EmailAddress example above. A __toString() method complements that for logging output and debugging purposes, so an instance can be embedded directly into a log entry or an exception message without the caller having to access individual properties manually.
Comparing several Value Objects in arrays or collections hides a trap: PHP functions such as array_unique() or in_array() use loose comparison or == by default, not your own equals() method. Anyone who wants to deduplicate such objects in an array must either iterate over the elements manually and call equals(), or use the instance's __toString() output as a unique array key, provided the string representation fully captures equality.
For larger domain models, it is often worth building a dedicated, typed collection class that holds such objects and offers methods such as contains() that internally use equals() instead of a built-in PHP comparison. That way, the comparison logic stays bundled in one place instead of implicitly relying on the default behavior of ==, which can behave unexpectedly depending on the object structure.
9. Entity and Value Object side by side
After the Money and EmailAddress examples, the difference between an Entity and a Value Object can be summarized in a compact overview. The choice between the two concepts is not an academic nuance, it directly determines how an object may be compared, stored, and changed.
| Criterion | Entity | Value Object |
|---|---|---|
| Identity | Carries its own ID, stays stable over its lifetime | Has no identity, exists only through its values |
| Equality | Compared by ID, regardless of the rest of the state | Compared across all attributes via equals() |
| Mutability | State changes over time, the ID stays the same | Immutable, changes produce a new instance |
| Lifecycle | Created, changed, persisted, and eventually deleted | Created and replaced by a new instance on change |
| Example | Customer, Order, Product | Money, EmailAddress, DateRange, Percentage |
In practice, most domain models are a mix of both concepts: Entities with identity that hold a number of Value Objects as attributes. An order, as an Entity, references several Money amounts for line item prices and the total, an EmailAddress for the billing contact, and a DateRange for the delivery window, without the order itself having to validate or compare any of those details again.
10. Summary
A cleanly modeled Value Object always solves the same underlying problem: values, validity, and equality that would otherwise be scattered across services, controllers, and forms get bundled in a single place in the code. A dedicated equals() method makes value comparison explicit and independent of PHP's built-in == behavior. readonly properties and wither methods enforce immutability, so that a once-created instance carries the same, guaranteed-valid state for its entire lifetime. Validation inside the constructor ensures that an invalid state can never come into existence in the first place.
The Money and EmailAddress examples show how these principles translate into real, production-ready classes, including normalization, arithmetic without mutation, and serialization via JsonSerializable. Just as important is the conscious boundary: as soon as an object needs its own identity over time, it is no longer an Entity, or rather it is a Value Object that has outgrown its boundaries, or conversely an Entity that should have been modeled as one from the start.
Modeling Value Objects correctly, the essentials at a glance
Equality
Dedicated equals() method instead of == or ===. Compares values, not object identity.
Immutability
readonly properties plus wither methods like withEnd() instead of setters that mutate state.
Validation
Check invariants in the constructor. An invalid Value Object must never be able to come into existence.
Boundaries
Keep it small and focused. If the object needs identity over time, it is an Entity, not a Value Object.
11. FAQ: Modeling Value Objects Correctly
1What is a Value Object?
2What distinguishes a Value Object from an Entity?
3Why isn't == enough for equality?
4Why be immutable?
5What is a wither method?
6Where does validation belong?
7Comparing in arrays and collections?
8When does a Value Object get too large?
9Several values in one Value Object?
10Need a dedicated interface for Value Objects?
Mironsoft
PHP architecture, Domain-Driven Design, and code reviews for clean object models
Object models that rule out entire classes of bugs from the start?
We review existing domain models, identify Primitive Obsession and anemic anti-patterns, and work with you on cleanly cut Value Objects and Entities, including tests and PHPStan coverage at a high level.
Code review
Analysis of existing domain classes for Primitive Obsession and missing invariants
Refactoring
Turning loose primitives into cleanly cut Value Objects with validation and tests
Training
Workshops on Domain-Driven Design, immutability, and clean object boundaries