Replacing Serialization Groups with Property-Level Security
Serialization groups have been the go-to mechanism for controlling field visibility in Symfony and API Platform for years. They work — but they introduce a layer of indirection that gets painful as your application grows. I recently removed the last serialization group from a production codebase and replaced it with ApiProperty(security:) on a dedicated input DTO. The result is cleaner, more explicit, and honestly more secure.
The Problem with Groups for Write Restrictions#
Consider a content platform where authors can PATCH their articles (title, body, status), but only editors can modify moderation fields (featured flag, editorial notes, publication date override). The classic approach is serialization groups:
// Entity
#[Groups(['article:patch'])]
private string $title;
#[Groups(['article:patch'])]
private string $body;
#[Groups(['article:patch'])]
private ArticleStatus $status;
// Fields without the group are invisible to authors
private bool $isFeatured = false;
private ?string $editorialNotes = null;
private ?\DateTimeImmutable $scheduledPublishDate = null;
Then you need a context builder to conditionally apply the group:
final readonly class AuthorContextBuilder implements SerializerContextBuilderInterface
{
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
if (
false === $normalization
&& Article::class === ($context['resource_class'] ?? null)
&& Request::METHOD_PATCH === $request->getMethod()
&& !$this->security->isGranted('ROLE_EDITOR')
) {
$context['groups'] = ['article:patch'];
}
return $context;
}
}
This works, but look at what’s happening: security logic lives inside serialization infrastructure. The groups are scattered across entity properties. The context builder decorates the serializer to inject role-based behavior. Testing requires mocking the entire serializer chain. And the intent — “authors can’t write these fields” — is nowhere near the field definitions.
A Dedicated Input DTO with Property Security#
API Platform has an ApiProperty::security mechanism, which evaluates a security expression per property during deserialization. Combined with the Symfony Object Mapper and a dedicated input DTO, this gives us a much cleaner approach.
First, the PATCH operation points to a dedicated input DTO:
#[Patch(
uriTemplate: '/articles/{id}',
uriVariables: ['id'],
input: ArticlePatchInput::class,
processor: ArticleStateProcessor::class,
)]
class Article
{
public string $title;
public string $body;
public ArticleStatus $status;
public bool $isFeatured;
public ?string $editorialNotes;
}
Then the input DTO declares exactly what each role can write:
use Symfony\Component\ObjectMapper\Attribute\Map;
#[Map(target: ArticleEntity::class)]
final class ArticlePatchInput
{
// Everyone with PATCH access can write these
public string $title;
public string $body;
public ArticleStatus $status;
// Only editors and admins can write these
#[ApiProperty(security: "is_granted('ROLE_EDITOR') or is_granted('ROLE_ADMIN')")]
public bool $isFeatured;
#[ApiProperty(security: "is_granted('ROLE_EDITOR') or is_granted('ROLE_ADMIN')")]
public ?string $editorialNotes;
#[ApiProperty(security: "is_granted('ROLE_EDITOR') or is_granted('ROLE_ADMIN')")]
public ?\DateTimeImmutable $scheduledPublishDate;
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
public bool $isLocked;
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
public ?string $lockReason;
}
That’s it. No context builder. No groups scattered across entity properties. The security intent is declared right where the property is defined. An author sends isFeatured in their PATCH payload? The property is silently ignored — API Platform handles it at the deserialization level.
What the Object Mapper Gives You#
The #[Map(target: ArticleEntity::class)] attribute tells the Symfony Object Mapper to map this DTO to the entity. API Platform reads the existing entity, maps it to the DTO for previous_data, deserializes the request into the DTO, then maps the DTO back to the entity before hitting the processor.
This means the processor receives a clean entity and a DTO as previous_data:
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$previousData = $context['previous_data'] ?? null;
// $data is the Article entity (after DTO→entity mapping)
// $previousData is the Article output DTO (before the request)
if ($previousData instanceof ArticleResource) {
$previousStatus = $previousData->status;
$newStatus = $data->getStatus();
if ($previousStatus !== $newStatus) {
if (!$this->stateMachine->canTransition($previousStatus, $newStatus)) {
throw new InvalidTransitionException(
\sprintf('Cannot transition article from "%s" to "%s".', $previousStatus->value, $newStatus->value)
);
}
}
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// No manual ObjectMapper::map() call needed — the framework handles output mapping
return $result;
}
No more map: false hack. No more manual ObjectMapper::map() call at the end of the processor to convert the entity back to the output DTO. The framework handles the full lifecycle.
What Gets Deleted#
In a real refactoring, this pattern let me remove:
- An entire
ContextBuilderservice — ~40 lines of serializer decoration plus its service registration - All
#[Groups([...])]attributes from both entity and output DTO - Manual
ObjectMapper::map()calls in the processor - The
map: falseflag on the PATCH operation - The
ObjectMapperInterfacedependency from the processor
And added a single input DTO — ~50 lines, self-documenting, security rules visible at a glance.
When to Use This Pattern#
This pattern works well when:
- Different roles can write different subsets of fields on the same resource
- You want security rules co-located with the fields they protect
- You’re already using DTOs (which you should be in API Platform 4)
Groups still have their place for controlling read serialization — showing different representations of the same resource to different consumers. But for write restrictions based on roles, ApiProperty(security:) on a dedicated input DTO is strictly better. The intent is explicit, the implementation is minimal, and the framework does the heavy lifting.