The domain has forty-one value objects, up from thirty-two in 2021, and the decision to adopt the pattern has never been reviewed. There is no symptom prompting this — it is a look at a pattern that is easy to apply and hard to stop applying.
The symptom
$ ./bin/value-objects --list --with-invariants
Money validates: currency, non-negative
EmailAddress validates: format
Percentage validates: 0-100
Sku validates: pattern
...
SupplierReference validates: nothing
CustomerName validates: nothing
OrderNote validates: nothing
...
41 types, of which 8 wrap a single string and
enforce nothing.Eight types that take a string, hold a string and return a string. The type safety is real — you cannot pass a supplier reference where a customer reference is expected — and it costs four files each once the normalizer, the cast and the tests are counted.
Why it happens
A pattern that is right thirty times becomes the default, and the default is applied to the thirty-first case without the question being asked again. Nothing in a review process prompts “should this be a type at all”.
The fix
The ones that were obviously right
public function __construct(
public int $minorUnits,
public Currency $currency,
) {
if ($minorUnits < 0) {
throw InvalidMoney::negative($minorUnits);
}
}
public function plus(self $other): self
{
if (! $this->currency->equals($other->currency)) {
throw InvalidMoney::currencyMismatch($this->currency, $other->currency);
}
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
what Money has prevented, from the git history:
four attempts to add amounts in different currencies
two float conversions caught at review
a negative total from a discount larger than the
order, which threw in a test rather than on an
invoice
three invariants, five years, seven prevented bugs that
left a trace. probably more that did not.The eight that were ceremony
final readonly class SupplierReference
{
public function __construct(public string $value) {}
}
// and the four files: the class, an API normalizer, a
// database cast, and a test asserting a string
// round-trips.
Deleting eight of these removed thirty-two files and produced a churn of about four hundred lines across the application, which is not free. The argument for keeping them is a type system that PHP does not have — a type alias would give the same distinction for no files, and there is no such thing.
The one that was wrong in an interesting way
// added 2022, and correct until 2025
final readonly class VatRate
{
public function __construct(public int $basisPoints)
{
if (! in_array($basisPoints, [0, 500, 2000], true)) {
throw InvalidVatRate::notAUkRate($basisPoints);
}
}
}
// the business started selling into a second
// jurisdiction. the invariant was a UK business rule
// encoded as a type constraint.
A value object that encodes a business rule is right until the rule changes, and the failure was loud and expensive — every historical rate object was still valid and every new one threw. The distinction that matters is between an invariant of the concept (a percentage is between zero and a hundred) and a rule of the business (a VAT rate is one of three values), and only the first belongs in a constructor.
The serialization registry, three years on
three contexts, decided in 2023:
api 41 normalizers, all used
csv 14, of which 11 used
messages 22, all used
a fourth was proposed in 2024 for a partner feed and
turned out to be the api format with two fields
removed — a sparse fieldset rather than a context.
three has held. the registry is 77 small classes, which
is a lot of files and each one is six lines.Seventy-seven normalizers is the cost that would have been avoided by putting jsonSerialize on the types, and the thing it bought is that the CSV format changed twice without touching the API. That is the trade and after three years it still looks correct — the counterfactual is a serialization change that could not be made without a version.
The two where equality was still ambiguous
public function isSameLocation(self $other): bool
{
return $this->line1 === $other->line1
&& $this->postcode === $other->postcode;
}
public function equals(self $other): bool
{
return $this == $other; // includes the delivery note
}
Two addresses with the same lines and different delivery notes are the same location and not the same value, and both questions get asked — deduplication wants the first and a change audit wants the second. Naming them separately is what made the question answerable, and it had been sitting unnamed since 2021 with callers using whichever method they found.
What the analyser gave that runtime types did not
the runtime enforces that you cannot pass an
EmailAddress where a Money is expected. the analyser
additionally caught, over five years:
a Money in a numeric comparison, 4 times
an array of Money where a Money was expected
a nullable value object passed to something that
assumed presence, 22 times
the third is the useful category, and it is about
nullability rather than about value objects — the
types made it visible.Verifying it worked
$ ./bin/value-objects --count
33 # was 41
$ ./bin/value-objects --without-invariants
0
$ git diff --stat HEAD~6 | tail -1
148 files changed, 302 insertions(+), 688 deletions(-)
$ vendor/bin/phpstan analyse --level=9
[OK] No errors
$ vendor/bin/phpunit
Tests: 1,688 passedThirty-three types, all of which enforce something, is the outcome and the rule that produced it — a value object must have an invariant of the concept rather than of the business. Whether the eight deletions were worth four hundred lines of churn is genuinely arguable and the rule is worth more than the deletions.
What this costs
A pattern that is easy to apply and hard to stop applying, now with a rule that is a review question rather than a mechanism. Nothing prevents the thirty-fourth type, and the person adding it will have a good reason — the eight that were deleted each had one.
The VAT rate case is the one worth carrying forward. A constructor that throws is a claim about what is possible, and encoding a business rule there means a change to the business is a change that invalidates historical objects. The rule now says invariants of the concept only, which is a distinction that requires judgement at exactly the moment nobody wants to exercise it.