Where the built-in ObjectNormalizer reaches its limits and how to extend it deliberately
Symfony's ObjectNormalizer walks every property of an object via reflection and turns it into an array, which works well for plain DTOs. As soon as an object is a value object with its own internal structure, part of a polymorphic class hierarchy, or references other objects that point back at it, that generic reflection logic runs into real limits. This article shows how to write your own transformations with NormalizerInterface and DenormalizerInterface, how to control their priority against the built-in normalizers, and how to resolve circular references deliberately instead of running into an infinite loop.
Table of Contents
- 1. Why the Built-in ObjectNormalizer Is Not Always Enough
- 2. NormalizerInterface and DenormalizerInterface in Detail
- 3. supportsNormalization() and Priority Over Built-in Normalizers
- 4. Normalizing Polymorphic Objects with a Discriminator Field
- 5. Serializing Value Objects Compactly Instead of as Nested Arrays
- 6. Detecting Circular References and Resolving Them with the CircularReferenceHandler
- 7. Reusing the Inner Serializer with NormalizerAwareInterface
- 8. Denormalization: Error Handling and How It Interacts with Validation
- 9. Testing Custom Normalizers and Integrating Them in Practice
- 10. Summary
- 11. FAQ
1. Why the Built-in ObjectNormalizer Is Not Always Enough
The ObjectNormalizer works generically: it walks through every public property and getter of an object, using reflection or a property accessor, and recursively builds an array out of it. That works reliably for flat DTOs with scalar fields, but reaches its limits as soon as the desired JSON shape structurally diverges from the internal object layout. A Money value object with internal fields amount and currency might need to be output as a single string like '12.50 EUR', and the ObjectNormalizer offers no clean way to do that without bending the object's own structure just for serialization's sake.
The problem becomes even clearer with polymorphic object hierarchies, for example several payment methods that all implement a shared interface but carry different fields. The generic normalizer has no notion of a discriminator field that tells the client which concrete type it is looking at, and during denormalization it certainly cannot decide, without extra configuration, which concrete class to instantiate from an incoming JSON object. This is exactly what the two central extension points of the Serializer component, NormalizerInterface and DenormalizerInterface, are for: you implement them for specific classes without touching the generic handling of every other object.
2. NormalizerInterface and DenormalizerInterface in Detail
A custom normalizer implements normalize(), which turns the object into a serializable array or scalar, and supportsNormalization(), which tells the serializer whether this normalizer is responsible for the given piece of data. Since Symfony 6.3, getSupportedTypes() has been added as well, returning a mapping of class names to a boolean or null, which lets the serializer cache responsibility instead of calling supportsNormalization() on every single invocation. The denormalizer mirrors this with denormalize() and supportsDenormalization(), where denormalize() turns raw array data back into a typed object.
The example below shows a combined normalizer and denormalizer for an immutable Money value object that internally holds an integer amount in the smallest currency unit and an ISO currency code. It is worth noting that both interfaces can be implemented independently: you can write a pure normalizer without a denormalizer whenever an object is only ever serialized outward and never reconstructed from a request, such as a computed value like an order total.
<?php
declare(strict_types=1);
namespace App\Serializer\Normalizer;
use App\ValueObject\Money;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize(mixed $data, ?string $format = null, array $context = []): array
{
if (!$data instanceof Money) {
throw new \InvalidArgumentException('Expected instance of Money.');
}
return [
'amount' => $data->getAmount(),
'currency' => $data->getCurrency(),
];
}
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
{
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];
}
}
3. supportsNormalization() and Priority Over Built-in Normalizers
Symfony registers normalizers as services tagged serializer.normalizer and determines the responsible normalizer for each normalization by asking every registered normalizer, in priority order, whether it agrees to handle the current piece of data via supportsNormalization(). The first normalizer that agrees wins, which in practice means a custom normalizer needs a higher priority than the built-in ObjectNormalizer if it is meant to override the default behavior for a given class. Without an explicit priority, a custom service often ends up behind the ObjectNormalizer in the chain by accident, so the custom logic never actually gets invoked.
Priority is set either through the #[AutoconfigureTag('serializer.normalizer', ['priority' => 100])] attribute directly on the class or via the priority key on the tag in services.yaml. A higher number means the normalizer gets checked earlier. It is important to keep supportsNormalization() as narrow as possible, typically with a strict instanceof check, so the custom normalizer does not accidentally end up responsible for objects that should really be handled by the generic ObjectNormalizer. An overly broad support check can otherwise cause unrelated classes to be serialized incorrectly or incompletely, which in practice often only surfaces once a customer reports a broken API response.
4. Normalizing Polymorphic Objects with a Discriminator Field
For a class hierarchy like PaymentMethod with concrete implementations CreditCardPayment and PaypalPayment, a simple instanceof check inside the normalizer is usually enough to write an extra type field into the resulting array per concrete class, such as 'type' => 'credit_card'. Denormalization is the harder direction, since the type value first has to be read from the raw array data before it is even possible to decide which concrete class to instantiate. Symfony also offers a built-in ClassDiscriminatorMapping through the DiscriminatorMap attribute for exactly this mapping, described declaratively, without necessarily requiring a custom denormalizer.
Once the transformation becomes more complex than a pure type mapping, for example because certain fields need different validation or default values depending on the concrete type, a custom denormalizer still pays off. It reads the type value internally and delegates the actual object construction to the matching inner denormalizer. This combination of a DiscriminatorMap for pure type resolution and a custom normalizer for the finer transformation logic is, in practice, the most robust approach for growing polymorphic domain models, since both mechanisms can evolve independently of each other.
5. Serializing Value Objects Compactly Instead of as Nested Arrays
Value objects like Money, EmailAddress, or DateRange deliberately encapsulate invariants and internal structure that is usually irrelevant to the API consumer. Without a custom normalizer, the ObjectNormalizer would turn an EmailAddress object with the internal field value into a nested array like {'value': 'customer@example.com'}, even though an API client naturally just expects the plain string 'customer@example.com'. A custom normalizer can collapse that nesting by having normalize() return the scalar directly instead of an array, which the Serializer component explicitly allows as long as the chosen output format, JSON or XML, can represent the scalar correctly.
Denormalization works the other way around: the denormalizer accepts the incoming scalar and constructs the value object from it, and this is exactly where the validation logic already encoded in the value object should kick in, for example a format check for the email address inside its constructor. This pattern has the added benefit that invalid values surface as an exception already during denormalization, long before the application's actual business logic would have to deal with an inconsistent object, which makes the root cause of an error visible much closer to where it actually originates.
6. Detecting Circular References and Resolving Them with the CircularReferenceHandler
If two objects reference each other, say an Order object with a reference to its Customer and a Customer object holding a list of its Orders, the serializer will run into an infinite loop without a countermeasure, because normalizing the Customer triggers normalization of the Order again, which in turn tries to normalize the Customer. Symfony detects such cycles automatically based on object identity within a single normalization pass and, by default, throws a CircularReferenceException as soon as an object shows up a second time within the same nesting chain.
Instead of letting that exception propagate, you typically configure a circular_reference_handler in the serializer context, a callable that returns a replacement value instead of the full normalization, usually the affected object's id. This handler can be set globally in the serializer configuration or per call through the context parameter of serialize(), which is particularly useful for API endpoints that need differently nested responses depending on the use case. It is worth choosing the handler deliberately: a plain id is enough for most API responses, while some use cases prefer an entirely empty array or null instead, to explicitly signal to the client that no detail data is being delivered at that point.
7. Reusing the Inner Serializer with NormalizerAwareInterface
A custom normalizer does not have to normalize every nested object by hand. For parts of the structure it can reuse the actual serializer service by implementing NormalizerAwareInterface and pulling in the NormalizerAwareTrait. That gives the class access to $this->normalizer, the fully configured serializer including every other registered normalizer, and lets it simply call $this->normalizer->normalize($nestedObject, $format, $context) for nested objects instead of duplicating the normalization logic itself.
This pattern is especially valuable when a custom normalizer only needs to add a single extra field or rename a single field, while the rest of the object should still be handled by the ObjectNormalizer. In that case, you additionally inject the concrete ObjectNormalizer as a delegate, call its normalize() method for the base structure, and then enrich the resulting array with the extra fields afterward. This keeps the custom logic minimal and resilient against changes to other fields of the original class, changes that are easy to forget to mirror in a fully manual implementation.
8. Denormalization: Error Handling and How It Interacts with Validation
When denormalizing incoming requests, a custom denormalizer should clearly distinguish between structural errors and business validation errors. A missing required field or a field with the wrong type in the incoming array is a structural error, and it is best thrown as a NotNormalizableValueException, a dedicated exception class from the Serializer component that calling code can catch specifically and translate into a structured 400 response. Business rules such as an amount that is too low or an implausible date, on the other hand, belong in the Symfony Validator component, which runs against the finished object after denormalization.
This separation exists for a practical reason: the denormalizer should be able to robustly construct an object as long as the raw data is structurally plausible, while the actual business validation happens centrally and reusably through validator constraints, which can also be applied independently of serialization to form objects or objects created directly in code. Anyone who embeds business validation directly inside a denormalizer loses that reusability and has to duplicate the rules for every new input source, such as a CLI import, which unnecessarily increases maintenance effort over time.
9. Testing Custom Normalizers and Integrating Them in Practice
A custom normalizer can be tested in isolation, without the full serializer container, because it is just an ordinary class with clearly defined methods. A PHPUnit test instantiates the normalizer directly, calls normalize() with a concrete object, and asserts the resulting array against the expected structure, and a corresponding test for denormalize() checks the reverse path, including the cases where invalid raw data must trigger the expected exception. These tests run considerably faster than integration tests against the full serializer service while still reliably covering the actual core logic.
For registration in the container, automatic tagging is usually enough as long as autoconfiguration is enabled and the class implements one of the two interfaces, since Symfony automatically detects serializer.normalizer and serializer.encoder based on the implemented interface. Manual tag configuration in services.yaml, or an extra attribute on the class, is only needed when an explicit priority or a restriction to specific formats is required. In larger projects with many custom normalizers, it is worth consistently following a naming convention such as the Normalizer suffix, so the team can immediately tell which classes participate in the serializer pipeline.
| Aspect | ObjectNormalizer (Default) | Custom Normalizer |
|---|---|---|
| Approach | Reflection over all properties/getters | Explicit, manually defined transformation |
| Polymorphic types | No built-in concept for discriminators | Discriminator field set deliberately |
| Value objects | Produces a nested array | Can be collapsed into a scalar |
| Circular references | Throws an exception without a handler | Handler individually configurable |
| Performance | Reflection overhead per property | Direct access, usually faster |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Custom Normalizers and Denormalizers: The Essentials at a Glance
Interfaces
NormalizerInterface and DenormalizerInterface for deliberate, custom transformations.
Priority
A higher priority than the ObjectNormalizer is required to override its behavior.
Circular references
Resolve them deliberately via circular_reference_handler instead of an exception.
Testability
Normalizers are plain classes and can be tested in isolation with PHPUnit.