php yaml 102 lines · 3 tabs

Serializing a Money Value Object with a Custom Symfony Normalizer

Shared by codesnips Sep 2026
3 tabs
<?php

namespace App\Domain;

use InvalidArgumentException;

final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {
        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException(sprintf('Invalid currency code "%s".', $currency));
        }
    }

    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('Cannot add money with different currencies.');
        }

        return new self($this->amount + $other->amount, $this->currency);
    }

    public function toDecimal(): string
    {
        return number_format($this->amount / 100, 2, '.', '');
    }
}
3 files · php, yaml Explain with highlit

This snippet shows how an immutable Money value object is integrated into the Symfony Serializer so it round-trips cleanly through JSON without leaking its internal representation. The core idea is that domain types like money should never be stored or transmitted as bare floats — floating point rounding silently corrupts amounts — so the value is kept as an integer number of minor units (cents) plus an ISO currency code, and a dedicated normalizer decides how that appears on the wire.

In Money value object, the class is final and readonly: the constructor validates the currency with a regex and rejects non-integer amounts, and helper methods like add() guard against mixing currencies before returning a brand-new instance. Because it is immutable, the same object can be shared freely and never mutated by the serializer or consumers. The amount field is the authoritative integer; the human-facing decimal is derived only at the edges.

MoneyNormalizer implements both NormalizerInterface and DenormalizerInterface, so one class owns both directions of the transform. supportsNormalization() and supportsDenormalization() gate the class strictly to Money::class, which keeps it from interfering with other types the Serializer handles. In normalize() the object becomes a small assoc array exposing amount, currency, and a formatted formatted string for display — note that formatted is intentionally output-only. denormalize() reverses the process defensively: it requires the keys to be present and casts amount back to int, throwing NotNormalizableValueException (the exception the Serializer expects) when input is malformed, so bad payloads surface as clean 400s rather than type errors deep in the stack.

Serializer wiring demonstrates registration. Under autoconfiguration a normalizer tagged serializer.normalizer is picked up automatically, but the explicit services.yaml tag and priority are shown so the ordering relative to built-in normalizers is clear — a higher priority ensures the custom logic wins before ObjectNormalizer tries to reflect the class generically. A key trade-off: because formatted is not consumed on the way in, clients must send amount/currency, keeping the integer as the single source of truth and avoiding lossy decimal parsing.


Related snips

Share this code

Here's the card — post it anywhere.

Serializing a Money Value Object with a Custom Symfony Normalizer — share card
Link copied