An API that ships a new version cannot drop the old one overnight. Partners, a mobile app that hasn't been updated, a script written two years ago: they all still read v1. The data is the same, but the field names and the shape of the response have changed.
With the Symfony Serializer, groups already chose which properties are output. They said nothing about what they are called. Symfony 8.2 (still in development) fills that gap: #[SerializedName] and #[SerializedPath] now accept a groups argument, and a new context option, ignored_groups, leaves a group out for a single response.
The Practice exercise that showcases these features is about deleting loops. This article takes a step back: how two versions used to be served, what the new approach changes, and the pitfalls to know before adopting it.
The starting point
A bookshop publishes its books in three places. v1, which partners still read, answers with a flat shape and its old names:
{ "isbn": "978-0-15-601219-5", "name": "The Little Prince", "price\_cents": 790 }
v2 renamed the title and nests the price with its currency:
{ "isbn": "978-0-15-601219-5", "title": "The Little Prince", "price": { "cents": 790, "currency": "EUR" } }
The back office reads the same shape as v2, plus the stock. The Livre class carries v1, v2 and internal groups, but only one way of naming each property.
How we did it until now
One class per version
The most explicit solution: a BookV1 and a BookV2, each with its own names, and a bit of code to map the entity to one or the other. Each version's contract can be read at a glance, and that is a real advantage.
The cost shows up over time. Every field added to v2 raises the question for v1. Every format fix happens in two places. And a third version multiplies all of it. For versions that differ by three field names, that is a lot of code to maintain.
A loop after the serializer
The most common solution, and the exercise's before it is fixed: serialize with the version's group, then patch the array.
$books = [];
foreach ($this->normalizer->normalize($this->catalogue->books(), 'json', ['groups' => 'v1']) as $book) {
$books[] = [
'isbn' => $book['isbn'],
'name' => $book['title'],
'price\_cents' => $book['price']['cents'],
];
}
It works, but the v1 contract now lives in a controller, far from the class it describes. The day someone adds a field to the v1 group, it doesn't come out: the loop doesn't know about it. And the loop only works one way. If v1 also accepts writes, you need a second loop, in reverse, for deserialization.
A custom normalizer or name converter
The most "Symfony" solution: a normalizer that decorates ObjectNormalizer for books in v1, or a name converter that renames according to the context. Renaming then works both ways, and it stays out of the controller.
In practice, I see these classes age badly. The name converter is global to the Serializer: it receives every property of every class, and has to check the class and the version itself. The decorated normalizer has to find its place among the others, and must not forget nested objects. That is a lot of infrastructure to express "in v1, title is called name".
What Symfony 8.2 changes
One name per group
Both attributes become repeatable and take a groups argument:
#[Groups(['v1', 'v2'])]
#[SerializedName('name', groups: 'v1')]
public readonly string $title,
#[Groups(['v1', 'v2'])]
#[SerializedName('price\_cents', groups: 'v1')]
#[SerializedPath('[price][cents]', groups: 'v2')]
public readonly int $priceInCents,
Serialized with the v1 group, the title comes out as name; with v2, it keeps its PHP name. The price comes out flat as price_cents in v1, and nested as price.cents in v2. Each version's contract is back on the class, and the controller shrinks to one line:
return new JsonResponse($this->normalizer->normalize($this->catalogue->books(), 'json', ['groups' => 'v1']));
The renaming also applies to deserialization: a v1 body that sends name does fill $title, with no second loop. YAML and XML configuration follow suit, with a serialized key and a <serialized> element.
Leaving a group out
The stock belongs to the v2 and internal groups. The back office serializes v2 and sees it; the public API serializes v2 too, and must not see it. Before 8.2, you needed an unset() afterwards, or an extra group on every other property. The AbstractNormalizer::IGNORED_GROUPS context option settles it:
$this->normalizer->normalize($this->catalogue->books(), 'json', [
AbstractNormalizer::GROUPS => 'v2',
AbstractNormalizer::IGNORED\_GROUPS => 'internal',
]);
A property that belongs to an ignored group is not output, even if it also belongs to a requested group. Exclusion wins. With a context builder, it's withIgnoredGroups('internal').
The pitfalls
The ungrouped name is the fallback
A #[SerializedName] without a groups argument applies to every group. Combined with a per-group name, it becomes the fallback: the group's name wins when that group is requested, otherwise the ungrouped name applies.
Handy, but it backfires quickly. In the exercise, the price started with an ungrouped #[SerializedPath('[price][cents]')]. Add a #[SerializedName('price_cents', groups: 'v1')] without touching the path, and v1 finds two answers: a name for its group, and a fallback path. When serializing, the path silently wins: the price stays under price.cents, and price_cents never appears. When deserializing, the Serializer refuses to choose and throws a LogicException ("Found SerializedName and SerializedPath attributes"). Either way, the path has to be tied to the v2 group only.
The silent case is the most dangerous one. My rule: as soon as a property has a name per group, I give a group to every one of its names and paths. The implicit fallback is the first thing you overlook when rereading the class.
Several groups requested: the declaration decides
What happens if you request ['v1', 'v2'] and the property has a name for each? The first declared name whose group is requested wins. The order of the groups in the context doesn't matter. This is deliberate, so that two controllers listing the groups in a different order get the same response. But it means you can't pick a version by putting its group in front of the others. One response is one version, so one version group.
A property without a group disappears
This isn't new, but it gets rediscovered every time groups are introduced: as soon as the context requests groups, a property without #[Groups] is no longer output. Symfony 8.2 adds an option to get around this without annotating the whole class: with AbstractNormalizer::ENABLE_DEFAULT_GROUPS, ungrouped properties join the Default group and the class's short name (Livre), provided you request one of them in the context, for example ['v1', 'Default'].
ignored_groups is not #[Ignore]
Both remove properties, but not in the same place:
-
#[Ignore]sits on the property, in the class. The property is never output, anywhere, and never read in. -
ignored_groupsis passed in the context. It only applies to that call: the back office, which doesn't pass it, still sees the stock. -
ignored_attributes, which has been around for a long time, removes properties by name. It works, but every controller has to know the list, and a property added to theinternalgroup later won't be left out. Withignored_groups, the decision stays on the class.
One detail that matters for an API: the exclusion also applies to deserialization. A stock sent by a public API client is ignored, like an unknown key. Be careful if the property is a required constructor argument, as in the exercise: deserialization then fails for lack of a value. Give it a default value, or keep that property out of the constructor.
Test the contract on the response
The risk with these attributes is that a change to the class alters a version without anyone noticing. So I test each version against the exact response, not against the presence of a few keys:
$this->assertSame(
['isbn' => '978-0-15-601219-5', 'name' => 'The Little Prince', 'price\_cents' => 790],
$this->fetch('/api/v1/books')[0],
);
An assertSame on the whole array fails if a field appears, disappears or gets renamed. That is exactly what a v1 partner would notice. The exercise's tests do the same for v2 and the back office.
When to keep two classes
Per-group names fit when the versions share the same model and only differ in shape: names, nesting, a few fields more or less. That is the most common case, and it's where the new feature removes the most code.
I keep separate classes when the versions diverge in substance: a field computed differently, a resource split in two, validation that is no longer the same. Stacking attributes for each version on a single property then becomes unreadable, and a dedicated class becomes the clear solution again. A good indicator: if a property carries more than two different names, the question is worth asking.
My takeaway
Symfony 8.2 doesn't change how to think about groups; it completes what they could do. A group already chose a version's fields; now it also gives their names and shape. And ignored_groups replaces the unset() calls lingering in controllers. Each version's contract goes back on the class it describes, and it holds both ways.
The exercise takes a few minutes in the browser, on a development version of Symfony 8.2. The three requests in the "Requests" tab must respond exactly as before, without a single loop in the controller.
Top comments (0)