Thirty-two value objects in the domain, each enforcing its own invariants, and every API resource unwrapping them by hand into arrays. The types were doing their job inside the domain and were a liability at every boundary.
The symptom
public function toArray($request): array
{
return [
'id' => $this->id->value,
'email' => $this->email->address,
'total' => [
'amount' => $this->total->cents,
'currency' => $this->total->currency->code,
],
];
}
// × 22 resources, and the Money shape written out four
// different ways across them.
Four spellings of the same money shape across twenty-two resources is the diagnostic. There is no single place that says how a Money is represented over the wire, so each resource invented one.
Why it happens
A value object is defined by its invariants, and serialization is not one of them. Putting the wire format on the class couples the domain to a transport; leaving it off means every caller decides, which is what happened.
The fix
Why JsonSerializable is the wrong first answer
final readonly class Money implements JsonSerializable
{
public function jsonSerialize(): array
{
return ['amount' => $this->cents, 'currency' => $this->currency->code];
}
}
// which commits the domain type to one representation
// forever. the API wants { amount, currency }; the CSV
// export wants "49.00 GBP"; the message payload wants
// 4900 and a separate field; a legacy consumer wants a
// float.
One method, four consumers with different requirements. The interface is fine for a type with exactly one representation and it is a trap for anything that crosses more than one boundary — which is most value objects, eventually.
A normalizer keyed by class
interface Normalizer
{
public function supports(object $value): bool;
public function normalize(object $value): mixed;
}
final class MoneyApiNormalizer implements Normalizer
{
public function supports(object $v): bool { return $v instanceof Money; }
public function normalize(object $v): array
{
return ['amount' => $v->cents, 'currency' => $v->currency->code];
}
}
public function normalize(mixed $value): mixed
{
if (! is_object($value)) { return $value; }
foreach ($this->normalizers as $n) {
if ($n->supports($value)) { return $n->normalize($value); }
}
throw new NoNormalizerFor($value::class);
}
Throwing on an unregistered type rather than falling back to get_object_vars is the decision that makes this safe — a silent default would serialise a new value object’s internals, including anything private, the first time somebody returned one from a resource.
One registry per context
three registries, same value objects:
api Money → { amount: 4900, currency: "GBP" }
csv Money → "49.00"
messages Money → { cents: 4900, ccy: "GBP" }
more code than one, and the point: a change to the CSV
format cannot affect the API, and the message format is
frozen by a contract with consumers.
32 types × 3 contexts = 96 normalizers in theory; 41 in
practice, because most types appear in one context.The database side
public function get($model, string $key, $value, array $attrs): ?Money
{
return $attrs["{$key}_cents"] === null
? null
: new Money($attrs["{$key}_cents"], new Currency($attrs['currency']));
}
public function set($model, string $key, $value, array $attrs): array
{
return [
"{$key}_cents" => $value?->cents,
'currency' => $value?->currency->code,
];
}
A cast reading and writing several columns is the shape most value objects need and it is the one least covered by documentation. The single shared currency column is a deliberate simplification — every money on a row is in the same currency here, and a type that did not have that property would need a column each.
Round-tripping, and the currency that was lost
// the bug, found by a property test
$normalized = $registry->normalize(new Money(4900, new Currency('JPY')));
// ['amount' => 4900, 'currency' => 'JPY']
$restored = $denormalizer->denormalize($normalized, Money::class);
// Money(4900, GBP)
// the denormalizer had a default currency, added for a
// test fixture in 2021, and it silently won whenever the
// payload was read through one particular path.
A default in a denormalizer is almost always a bug waiting for the right input. This one had been correct for every currency the application actually used until an order arrived in yen, and the property test found it in the first run.
Equality, and why == is not enough
$a == $b; // true — loose comparison walks properties
$a === $b; // false — different instances
// == works until a property is added that should not
// participate in identity:
final readonly class Money
{
public function __construct(
public int $cents,
public Currency $currency,
public ?string $sourceReference = null, // ← not identity
) {}
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency->equals($other->currency);
}
}
Verifying it worked
$ vendor/bin/phpunit --filter RoundTrip
32 types × 3 contexts, 100 generated cases each
Tests: 96, Assertions: 9,600 — OK
$ grep -rc 'cents' src/Http/Resources/ | awk -F: '{s+=$2} END {print s}'
0 # was 41
$ curl -s /api/orders/8814 | jq -c '.total'
{"amount":4900,"currency":"GBP"}
$ curl -s /api/invoices/22 | jq -c '.total'
{"amount":4900,"currency":"GBP"}
# the same shape, from two resources, for the first timeThe property test round-tripping a hundred generated values per type per context is what caught the currency default, and it is the assertion that has to exist — a hand-written example test would have used GBP.
What this costs
A second place every new type must be registered, and a failure that is a thrown exception at runtime rather than a compile error. Adding a value object and returning it from a resource without a normalizer is a 500 in production if the resource is not covered by a test.
A test that reflects over the domain namespace and asserts every value object has an API normalizer would close that, and we have not written it — partly because not every value object should be exposed, so the assertion needs an allow-list, and an allow-list is another thing to forget.