Symfony Serializer: DTO Mapping and Normalization
AI generated
SF
{ }
Symfony · Serializer · DTO · Normalization
Symfony Serializer:
DTO Mapping and Normalization

Manually extracting JSON data from request arrays and mapping it into PHP objects is error-prone and does not scale. The Symfony Serializer normalizes objects into JSON and denormalizes JSON into type-safe PHP DTOs, with serialization groups, custom normalizers and constructor promotion.

16 min read ObjectNormalizer · Serialization Groups · Custom Normalizer · DTO Pipeline Symfony 7.x · PHP 8.4 · Serializer Component

1. Why Symfony Serializer instead of json_encode

json_encode($entity) is the obvious way in PHP to turn an object into JSON. It works, right up until the moment you serialize a Doctrine entity that contains circular references or lazy-loaded collections. Then serialization either aborts or hundreds of database rows end up in the response because the ORM eagerly loads every relation. The Symfony Serializer solves this problem with normalizer classes that control exactly which fields get serialized in which context.

The second advantage of the Symfony Serializer lies in the reverse direction: not just objects to JSON, but JSON back into PHP objects. Anyone processing API requests does not want to write raw array access on $request->request->get('name') and validate every value by hand. With the serializer you map the request body directly onto a type-safe DTO, validate it with Symfony constraints, and immediately have a structured, typed PHP object for further processing. That is more robust, faster to write and easier to test.

2. Architecture: normalizers, encoders and context

The Symfony Serializer is not a monolithic object but a stack of normalizers and encoders. Normalization transforms a PHP object into an array structure (and back during denormalization). Encoding converts the array into a string in the target format, JSON, XML, CSV or a custom format. The ObjectNormalizer is the default normalizer for arbitrary PHP objects: it uses property reflection or getter/setter methods to extract fields. The PropertyNormalizer accesses properties directly, and the GetSetMethodNormalizer works exclusively through getters and setters.

Context is a central concept in the Symfony Serializer: an array of key-value pairs that controls the normalization process. AbstractObjectNormalizer::GROUPS activates serialization groups. AbstractObjectNormalizer::SKIP_NULL_VALUES excludes null fields from the output. AbstractNormalizer::OBJECT_TO_POPULATE specifies an existing object to populate during denormalization instead of creating a new one. AbstractObjectNormalizer::ENABLE_MAX_DEPTH limits the depth of nested objects. These context parameters steer the serializer's behavior without any code changes.


<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\CreateProductRequest;
use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Context\Normalizer\ObjectNormalizerContextBuilder;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * Controller demonstrating Symfony Serializer normalization and denormalization.
 */
final class ProductController extends AbstractController
{
    public function __construct(
        private readonly SerializerInterface $serializer,
    ) {}

    #[Route('/api/products/{id}', methods: ['GET'])]
    public function show(Product $product): JsonResponse
    {
        // Build normalization context with Serialization Groups
        $context = (new ObjectNormalizerContextBuilder())
            ->withGroups(['product:read'])
            ->withSkipNullValues(true)
            ->toArray();

        // Normalize entity to JSON, only fields in 'product:read' group
        $json = $this->serializer->serialize($product, 'json', $context);

        return new JsonResponse($json, Response::HTTP_OK, [], json: true);
    }

    #[Route('/api/products', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        // Denormalize JSON request body into a typed DTO
        $dto = $this->serializer->deserialize(
            $request->getContent(),
            CreateProductRequest::class,
            'json',
        );

        // $dto is now a fully typed PHP object, no manual array access
        return new JsonResponse(['id' => 'created'], Response::HTTP_CREATED);
    }
}

3. Serialization groups for differentiated output

Serialization groups are the most powerful feature of the Symfony Serializer for API development. Every field of a class receives one or more groups through the #[Groups] attribute. In the serialization context you activate the desired groups, and only fields belonging to those groups get serialized. That lets a single entity class produce different API responses: a compact list view with an id and a name, a full detail view with every field, and an admin view with internal fields.

Group naming follows a convention that makes the code easier to read: {entity}:list for the collection view, {entity}:read for the detail view, {entity}:write for incoming data and {entity}:admin for privileged output. Nested objects get their own groups to control how deep serialization descends into the object hierarchy. Without groups, the Symfony Serializer would recursively serialize every relation, with the result that a simple product query pulls the entire category tree structure into the response.

4. Denormalizing DTOs with constructor promotion

The DTO pattern combined with the Symfony Serializer is one of the cleanest ways to process incoming API data. A DTO (Data Transfer Object) is a plain PHP class whose only purpose is carrying data. With PHP 8.4 constructor property promotion, a complete DTO can be defined in a few lines: readonly properties, typed, immutable after creation. The Symfony Serializer can populate such readonly classes through the constructor, it recognizes which constructor parameters correspond to which JSON fields and calls the constructor with the deserialized values.

The advantage over direct entity mapping: the DTO is decoupled from the database structure. Fields on the DTO can be named differently than in the database, can contain calculations or transformations, and can carry validation constraints tailored to the API context. The entity stays clean and focused on its persistence responsibility, while the DTO models the API input contract. When the API interface changes, only the DTO changes, not the entity.


<?php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * DTO for creating a new product via the REST API.
 * Immutable after construction, Symfony Serializer populates via constructor.
 */
final readonly class CreateProductRequest
{
    public function __construct(
        #[Groups(['product:write'])]
        #[Assert\NotBlank(message: 'Product name is required.')]
        #[Assert\Length(min: 2, max: 255)]
        public string $name,

        #[Groups(['product:write'])]
        #[Assert\NotNull]
        #[Assert\Positive(message: 'Price must be a positive number.')]
        public float $price,

        // Map snake_case JSON field to camelCase PHP property
        #[SerializedName('category_id')]
        #[Groups(['product:write'])]
        #[Assert\Positive]
        public ?int $categoryId = null,

        #[Groups(['product:write'])]
        #[Assert\Length(max: 2000)]
        public ?string $description = null,
    ) {}
}

// Normalization output DTO, different fields for read vs. write context
final readonly class ProductResponse
{
    public function __construct(
        #[Groups(['product:list', 'product:read'])]
        public int $id,

        #[Groups(['product:list', 'product:read'])]
        public string $name,

        // Price only in detail view, not in list
        #[Groups(['product:read'])]
        public string $price,

        #[Groups(['product:read'])]
        public ?string $description,

        // Category is a nested object, serialized with its own groups
        #[Groups(['product:read'])]
        public ?CategoryResponse $category,
    ) {}
}

5. Custom normalizers for complex types

The Symfony Serializer ships built-in normalizers for most PHP types, but for domain-specific types, money objects, value objects, specific date formats, you need your own normalizer classes. A custom normalizer implements NormalizerInterface and optionally DenormalizerInterface. The supportsNormalization() method decides which types this normalizer is responsible for. The normalize() method converts the object into a scalar value or an array.

A classic example: a Money value object that encapsulates an amount and a currency. Without a custom normalizer, the Symfony Serializer would serialize it as an object with amount and currency properties. With a custom normalizer you can output it as "price": "29.99 EUR" or as "price": {"amount": 2999, "currency": "EUR", "formatted": "29,99 €"}, depending on the API convention. The denormalizer converts the JSON value back into the Money object, so DTOs and entities can hold directly typed Money properties.


<?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;

/**
 * Custom normalizer for the Money value object.
 * Serializes as {"amount": 2999, "currency": "EUR"} and deserializes back.
 */
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
    /**
     * Convert Money object to a structured array representation.
     */
    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        /** @var Money $money */
        $money = $object;

        return [
            'amount'    => $money->getAmountInCents(),
            'currency'  => $money->getCurrency(),
            'formatted' => $money->format(),  // e.g., "29,99 €"
        ];
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Money;
    }

    /**
     * Convert array back to a Money value object.
     */
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
    {
        if (is_array($data)) {
            return Money::fromCents((int) $data['amount'], (string) $data['currency']);
        }

        // Accept simple float values as EUR amounts for backward compatibility
        return Money::fromFloat((float) $data, 'EUR');
    }

    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];
    }
}

6. Type-safe deserialization with PHP 8.4

PHP 8.4 brings property hooks and improved readonly semantics, opening up new possibilities for type-safe DTOs that the Symfony Serializer fully supports. Enum types in DTOs are deserialized automatically: a JSON value "status": "published" is converted into the corresponding enum case ArticleStatus::PUBLISHED when the DTO property has the enum type. For backed enums (string- or int-backed) this works without a custom normalizer, the built-in BackedEnumNormalizer handles the conversion.

Union types in DTOs, however, need a custom normalizer or discriminator configuration to determine which class should be instantiated during deserialization. The Symfony Serializer offers the DiscriminatorMap mechanism: a field in the JSON identifies the concrete type, and the serializer picks the corresponding PHP class. That is the classic polymorphism pattern for API design, where a single endpoint can process different subtypes without type checks in the controller code.

7. Nested objects and relations

Nested objects in the Symfony Serializer are controlled through group configuration. A Product entity with a Category relation only serializes the category if both classes carry the corresponding #[Groups] annotations and the group is active in the context. Without groups, the serializer would recursively serialize the entire object hierarchy, which leads to circular references and a MaxItemCountException in Doctrine entities with bidirectional relations.

The #[MaxDepth(n)] annotation limits the depth of nested serialization: at a depth of 2, objects at level 3 are no longer fully serialized, only their id value or a configurable circular-reference-handler result gets used. For complex graphs it is often better to use direct response DTOs instead of Doctrine entities: the controller loads the entity, explicitly maps it into a response DTO and serializes only the DTO. That gives complete control over the output structure without having to rely on group configuration.

8. Combining deserialization and validation

The natural extension of Symfony Serializer deserialization is immediately validating the deserialized DTO. The pattern is straightforward: deserialize the request body, call the validator service, turn violations into a structured error response. Symfony 7 makes this even more compact with the #[MapRequestPayload] attribute: the attribute on a controller parameter automatically handles deserialization, validation and error handling. The controller receives the validated DTO directly, or Symfony sends a 422 response with validation errors if the DTO is not valid.

For APIs that support multiple formats, JSON, XML, form data, the Symfony Serializer is the unified solution. The request's content-type header determines which decoder is used. The controller code stays identical, regardless of the input format. That is especially useful for legacy integrations that send XML while new clients use JSON, the same controller, the same validation, the same DTO.

Strategy Approach Suited for Drawback
Entity directly Serialize entity with groups Small APIs, fast development Database structure visible in the API
Response DTO Entity to DTO to Serializer Stable APIs, clear contract More mapping code required
Custom Normalizer Your own normalizer class Value objects, complex types More classes in the project
#[MapRequestPayload] Automatic deserialization plus validation Symfony 7 controllers, simple APIs Little control over the error format

9. Comparing serializer strategies

The best strategy depends on the project's requirements. For small internal APIs that rarely change, direct entity mapping with groups is sufficient. For public APIs with stability guarantees, the response DTO pattern is the better choice: the API structure is explicitly defined in a DTO class and does not change when the database structure gets refactored. For domains rich in value objects, custom normalizers come into play, extending the Symfony Serializer ecosystem with domain-specific types.

In projects using API Platform 4, the Symfony Serializer is already fully integrated: API Platform uses the same serializer, the same groups and the same custom normalizers. A custom normalizer class written for a controller endpoint also works automatically for API Platform resources. That makes the Symfony Serializer a project-wide infrastructure component rather than a tool duplicated per controller.

Mironsoft

Symfony API development, serializer architecture and DTO design

Building a Symfony Serializer and DTO pipeline?

We design and implement thoughtful serializer strategies for Symfony APIs, from serialization-group architectures through custom normalizers for value objects to a complete DTO pipeline with validation.

DTO Design

Request and response DTO architecture with constructor promotion and serialization groups

Custom Normalizer

Normalizers for value objects, money types, enums and domain-specific data structures

API Integration

Serializer strategy kept consistent across controller endpoints and API Platform resources

10. Summary

The Symfony Serializer is a powerful infrastructure component that goes far beyond simple JSON encoding. Serialization groups control, context-sensitively, which fields get serialized. Custom normalizers extend the system with domain-specific types. DTOs with PHP 8.4 constructor promotion receive incoming API data in a type-safe way. #[MapRequestPayload] combines deserialization and validation in a single controller annotation. All of these features work together to create a consistent data layer that serves both outgoing API responses and incoming requests.

The most important principle: do not serialize entities directly if the API is meant to offer a stable interface independent of the database structure. The response DTO pattern, mapping the entity into an explicit DTO and serializing only the DTO, hands control back and decouples the API contract from the persistence implementation. That is the foundation for maintainable, testable and long-lived Symfony APIs that can evolve independently of database migrations.

Symfony Serializer, the essentials at a glance

Serialization Groups

#[Groups] on properties plus context when serializing, differentiated output for list, detail and admin views without multiple classes.

DTO Deserialization

Readonly DTOs with constructor promotion, the serializer populates the constructor with deserialized values. Type-safe without manual array access.

Custom Normalizer

Implement NormalizerInterface plus DenormalizerInterface for value objects, money types and domain-specific data structures.

#[MapRequestPayload]

Symfony 7 attribute for automatic deserialization plus validation in controller parameters, no boilerplate in the controller body.

11. FAQ: Symfony Serializer and DTO Mapping

1What is the Symfony Serializer?
A component for bidirectional conversion: PHP objects to JSON/XML (normalization) and JSON/XML to PHP objects (denormalization), with groups, custom normalizers and DTO support.
2What are serialization groups?
#[Groups] marks properties, groups activated in the context determine which fields get serialized. Different outputs from one class without duplication.
3Deserializing readonly DTOs?
ObjectNormalizer recognizes readonly properties and populates the constructor. JSON fields get mapped to constructor parameters. No setter needed.
4When custom normalizer?
For value objects, money classes, enums with a special format or external classes without attribute support. Implement NormalizerInterface plus supportsNormalization().
5What does #[MapRequestPayload] do?
Symfony 7: automatic deserialization plus validation in a single controller attribute. On errors, Symfony sends a 422. The controller receives only the validated DTO.
6Preventing circular references?
Serialization groups: the other side without a group or with a limited group. Alternatively set #[MaxDepth] or serialize response DTOs instead of entities.
7Serializing Doctrine entities directly?
Possible with groups, but watch out for N+1 queries. For stable APIs, the response DTO pattern is the safer alternative, API structure independent of the database structure.
8Serializing PHP enums?
Backed enums automatically via BackedEnumNormalizer, the value is output, converted back into the enum case on deserialization. No custom normalizer needed.
9normalize() vs. serialize()?
normalize() produces a PHP array. serialize() produces a string (JSON/XML). serialize() internally calls normalize() plus encode(). normalize() is useful for debugging the intermediate structure.
10Symfony Serializer with API Platform?
Yes, API Platform internally uses the same serializer. Custom normalizers and groups automatically apply to API Platform resources too. A single configuration, project-wide.