Groups and Shaping Output
One entity, several JSON shapes: groups to pick fields, SerializedName to rename one, and why the shape is a contract.
return $this->json($article, 200, [], ['groups' => ['article:read']]);One entity usually needs more than one JSON shape. The list endpoint wants three fields, the detail endpoint wants twelve, and the write endpoint must accept only the fields a client is allowed to set. Serialization groups do that without a class per shape.
How it works
Put #[Groups(['article:read'])] on a property or on a getter. A property can belong to several groups. When a group is requested, only members of that group are serialized — and anything with no group at all disappears. That is the point, and also the first surprise.
Request groups through the context: ['groups' => ['article:read']]. The string key is the shorthand. [AbstractNormalizer::GROUPS => ['article:read']] is the longhand, and the constant's value is the string 'groups', so the two arrays are identical. The special group '*' serializes everything, grouped or not.
Renaming a field
#[SerializedName('published_at')] changes the key in the output without renaming the PHP property. Reach for it when the JSON convention and the PHP convention differ, or when a field was published under a name you have since outgrown.
The shape is a contract
As soon as a client reads your JSON, those keys are an interface. Adding a key is usually safe. Renaming or removing one breaks callers you cannot see and cannot fix. Name groups after the representation rather than the caller — article:list, not mobile — and change a group as deliberately as you would change a public method signature.
Example
// src/Entity/Article.php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ArticleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// In three groups: both read shapes, and the write shape.
#[ORM\Column(length: 180)]
#[Groups(['article:read', 'article:list', 'article:write'])]
public string $title = '';
// Too long for a list; present in the detail shape and writable.
#[ORM\Column(type: 'text')]
#[Groups(['article:read', 'article:write'])]
public string $body = '';
// The property is publishedAt; the JSON key is published_at. Not in the
// write group, so a client cannot set it.
#[ORM\Column(nullable: true)]
#[Groups(['article:read', 'article:list'])]
#[SerializedName('published_at')]
public ?\DateTimeImmutable $publishedAt = null;
// No group at all, so it never appears in a grouped response.
#[ORM\Column]
public int $internalScore = 0;
// Groups work on getters too, and the key is still "id".
#[Groups(['article:read', 'article:list'])]
public function getId(): ?int
{
return $this->id;
}
}
// Output: with ['groups' => ['article:list']] this serializes to
// {"title":"Routing","published_at":"2026-02-01T09:00:00+00:00","id":7}When to use it
- Serve a short list payload and a full detail payload from the same entity.
- Stop a POST body from setting an identifier, a timestamp or an internal score.
- Publish a field as published_at while the PHP property stays publishedAt.
- Keep an old key alive for existing clients while new clients read the renamed one.
More examples
Two representations from one controller
The same entity, two shapes, one place each is chosen. Keeping the group name in the controller and the group membership on the entity means a new field is a one-line decision: which representations does it belong to. The two context forms are shown side by side because you will meet both in other people's code; neither is more correct.
// src/Controller/Api/ArticleController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
#[Route('/api/articles', name: 'api_articles_')]
final class ArticleController extends AbstractController
{
public function __construct(private readonly ArticleRepository $articles)
{
}
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse
{
// Fetch-joined, so serializing the nested author costs no extra query.
$articles = $this->articles->findLatestWithAuthor(20);
// Shorthand context key.
return $this->json($articles, Response::HTTP_OK, [], ['groups' => ['article:list']]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'], methods: ['GET'])]
public function show(int $id): JsonResponse
{
$article = $this->articles->find($id) ?? throw $this->createNotFoundException();
// Longhand. AbstractNormalizer::GROUPS is the string 'groups'.
return $this->json($article, Response::HTTP_OK, [], [
AbstractNormalizer::GROUPS => ['article:read'],
]);
}
}
// GET /api/articles returns 200 with a JSON array of the list shape.
// GET /api/articles/7 returns 200 with the fuller detail shape.A write group for input
Groups are a security boundary on the way in, not only a filter on the way out. Without the write group, a request body is free to set any writable property the normalizer can reach, including an identifier or a published date. <code>serializationContext</code> on <code>#[MapRequestPayload]</code> is where the input group goes; the response uses a read group, so the two directions never share a definition by accident.
// src/Controller/Api/CreateArticleController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
final class CreateArticleController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
#[Route('/api/articles', name: 'api_articles_create', methods: ['POST'])]
public function __invoke(
// Only article:write properties are read from the body. A client
// sending "publishedAt" or "internalScore" cannot set them.
#[MapRequestPayload(serializationContext: ['groups' => ['article:write']])]
Article $article,
): JsonResponse {
$this->entityManager->persist($article);
// persist() schedules the insert; flush() runs it.
$this->entityManager->flush();
return $this->json($article, Response::HTTP_CREATED, [], ['groups' => ['article:read']]);
}
}
// POST /api/articles returns 201 with the detail shape of the new row.What '*' exposes
Worth seeing once so you recognise the failure. <code>'*'</code> and omitting the groups option entirely produce the same thing: every readable property, including the ones nobody meant to publish. It is useful for an internal dump where you want the whole row, and it is exactly what a public endpoint must not do. Note that <code>#[SerializedName]</code> still applies, while <code>#[Ignore]</code> still wins.
// src/Api/ArticleDump.php
declare(strict_types=1);
namespace App\Api;
use App\Entity\Article;
use Symfony\Component\Serializer\SerializerInterface;
final class ArticleDump
{
public function __construct(private readonly SerializerInterface $serializer)
{
}
// For a support console or a fixture dump. Never for a public endpoint.
public function dump(Article $article): string
{
// '*' includes grouped and ungrouped properties alike.
return $this->serializer->serialize($article, 'json', ['groups' => '*']);
}
}
// Output: dump() on article 7 returns
// {"title":"Routing","body":"...","published_at":"2026-02-01T09:00:00+00:00",
// "internalScore":42,"id":7}
Discussion