Symfony 8 ignores your #[Groups] attribute

I upgraded a client's Symfony app to Symfony 8 recently. The upgrade itself went smoothly: dependencies resolved, the app booted, the test suite was green. A few days later a bug report came in that the invoice history was broken. Customers opened a PDF of an old invoice and got the letterhead, the logo, the table header, and then nothing. No line items, no totals. A beautifully rendered blank invoice.
The PDF is drawn client-side from a JSON endpoint, so I checked the API response first. The endpoint was returning HTTP 200 and a perfectly well-formed payload:
{
"contents": [[], [], [], []]
}Four line items, and every single one of them serialized as an empty object. The entity was fine. The database rows were fine. The controller was fine. The serializer just looked at each object, found nothing it considered worth serializing, and cheerfully returned {}.
The import that lies to you
The line items were annotated the way Symfony code has been annotated for years:
use Symfony\Component\Serializer\Annotation\Groups;
class InvoiceLineItem
{
#[Groups(['invoice'])]
private string $description;
}Spot the bug? Neither did I, for longer than I'd like to admit. The #[Groups] attribute is right there. It compiles. Static analysis was quiet. The app had shipped hundreds of invoices with exactly this code.
The problem is the namespace. Symfony moved serializer attributes from Serializer\Annotation to Serializer\Attribute back in 6.4, and kept the old namespace around as a deprecation shim. Symfony 8 pulled the plug, and it did it in a particularly sneaky way. The shim file still exists, but the class inside it is declared like this:
if (false) {
class Groups extends \Symfony\Component\Serializer\Attribute\Groups
{
}
}The if (false) block means IDEs and static analysis tools still see a class there, but at runtime the class simply does not exist. Loading the file pulls in the new Attribute\Groups and nothing else.
Why PHP never says a word
Here's the part that turns a removed class into a silent failure instead of a fatal error. PHP attributes are lazy. #[Groups(['invoice'])] isn't executed when the file loads; it's stored as metadata, a class name and some arguments, and nothing tries to resolve that class name until someone calls reflection and asks for an instance.
Symfony's attribute loader walks the reflection metadata looking for its attribute classes, the ones in Serializer\Attribute. Your attribute names a class from Serializer\Annotation, which is not on that list and doesn't even exist. So the loader does the only reasonable thing it can do with an attribute it doesn't recognize: it skips it. That's correct behavior, by the way. Your entity might carry attributes from Doctrine, from a validation library, from your own code. The serializer can't throw on every attribute that isn't meant for it.
The result is a perfect storm of nobody-is-wrong. PHP is right not to error on an unresolved attribute class. The serializer is right to ignore attributes it doesn't own. The shim is right to exist so old code doesn't fatal on load. And you're left with an entity whose serialization groups have quietly evaporated, so normalize($item, null, ['groups' => 'invoice']) matches zero properties and hands you an empty object.
No exception. No deprecation notice, because the deprecation period is over. Not even a log line.
The fix
One line per file:
use Symfony\Component\Serializer\Attribute\Groups;That's it. The attribute usage stays identical, only the import changes. Find every affected file with:
grep -rn 'Serializer\\Annotation' src/Fix them all in one pass, because this fails per-class: one entity on the new namespace and one on the old gives you a response where half the graph is populated and half is empty objects, which is even more confusing to debug. Then clear the cache, since serializer metadata is cached and will happily keep serving the old, empty mapping:
php bin/console cache:clearThe invoices came back immediately, line items and all.
The rest of the minefield
Groups is not the only class that moved. The same shim treatment applies to the whole old namespace: SerializedName, Ignore, Context, MaxDepth, DiscriminatorMap. Every one of them will compile from the old import and do absolutely nothing. Ignore is the scary one, since a silently dropped #[Ignore] doesn't remove data from your response, it adds data you meant to hide. Empty invoices are embarrassing; leaking a field you explicitly marked as ignored is a different category of morning.
The same annotation-to-attribute migration happened in Doctrine and in the Validator component, so if your upgrade path skipped a major version or two, grep for those old namespaces while you're at it.
Credit where due: after I published this, a commenter on Reddit pointed out that Rector catches this whole class of bug. They're right, and it stings a little, because the official upgrade guide tips Rector right there in the fix-your-deprecations step. I did the upgrade by hand anyway. Rector's Symfony sets rewrite the old Annotation imports to Attribute mechanically, and because it rewrites instead of checks, it's immune to the if (false) trick that keeps every checking tool quiet. Learn from my stubbornness: run it. And when you want to see what the serializer actually sees, bin/console debug:serializer App\Entity\Whatever prints the real metadata; my missing groups would have been plainly visible there.
Takeaway
After a Symfony major upgrade, green tests and a booting app don't prove your attributes are still being read. Anything that configures behavior through attributes can degrade into a no-op instead of an error, because unknown attributes are skipped by design. Grep for the old namespaces before you trust the output, and be suspicious of any API response that's the right shape but strangely empty. The serializer isn't broken. It's doing exactly what your imports told it to do, which is nothing.
Upgrading a Symfony app and hitting weirdness like this? Send me the details and I'll take a look.