<?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, '.', '');
}
}
<?php
namespace App\Serializer;
use App\Domain\Money;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize(mixed $object, ?string $format = null, array $context = []): array
{
\assert($object instanceof Money);
return [
'amount' => $object->amount,
'currency' => $object->currency,
'formatted' => $object->toDecimal() . ' ' . $object->currency,
];
}
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Money;
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
{
if (!is_array($data) || !isset($data['amount'], $data['currency'])) {
throw NotNormalizableValueException::createForUnexpectedDataType(
'Money requires "amount" (int) and "currency" (string).',
$data,
['array'],
$context['deserialization_path'] ?? null,
);
}
if (!is_numeric($data['amount'])) {
throw NotNormalizableValueException::createForUnexpectedDataType(
'"amount" must be an integer of minor units.',
$data['amount'],
['int'],
$context['deserialization_path'] ?? null,
);
}
return new Money((int) $data['amount'], (string) $data['currency']);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $type === Money::class;
}
public function getSupportedTypes(?string $format): array
{
return [Money::class => true];
}
}
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# With autoconfigure enabled the tag below is added automatically,
# but it is declared explicitly to pin the priority ahead of ObjectNormalizer.
App\Serializer\MoneyNormalizer:
tags:
- { name: serializer.normalizer, priority: 100 }
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
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.