Symfony Request DTOs and Response DTOs Instead of Arrays Everywhere
AI generated
{ }
GET
Symfony · PHP 8.4 · DTOs · Serializer · Validator
Request DTOs and Response DTOs
instead of arrays everywhere in Symfony REST APIs

Arrays as request and response data containers are the most common maintenance problem in Symfony REST APIs: no type safety, no autocompletion, no documentation generated from the code. PHP 8.4 readonly properties, the Symfony Serializer and the Symfony Validator turn DTOs into a clear upgrade, with less code, more safety and better IDE support.

16 min read Request DTOs · Response DTOs · Serializer · Validator · readonly PHP 8.4 · Symfony 7 · Constructor Property Promotion

1. The array problem in Symfony REST APIs

In many grown Symfony APIs the typical controller looks like this: $data = $request->toArray(), followed by manual access to $data['name'], $data['price'] ?? null, and a pile of isset checks. The result is code that is neither type-safe nor documented. The IDE has no idea what is inside the array. PHPStan finds no errors because arrays may contain anything. Renaming a field requires a search across the entire codebase. And if a field is optional, every single place has to check separately whether it is present.

The same problem exists on the output side: return $this->json(['id' => $product->getId(), 'name' => $product->getName(), ...]) exposes the entity structure directly, which leads to accidental data leakage as soon as new entity fields are added. There is no central place that defines which fields are visible for which endpoint. The serializer cannot check types. OpenAPI documentation has to be maintained manually because it cannot be generated from the code. DTOs solve all of these problems at once.

2. What DTOs are and what they are not

A Data Transfer Object (DTO) is an object whose sole purpose is to carry data between layers. It has no business logic, no database connection, no services as dependencies. It is a type-safe data structure. In PHP 8 with Constructor Property Promotion and readonly properties, a DTO is a final class with a single constructor and exclusively public readonly properties, no setter, no getter, no boilerplate.

A request DTO is the input DTO for an API endpoint: it represents the validated, typed request body or query parameter set. A response DTO is the output DTO: it defines exactly which fields appear in the API response. DTOs are not entities, they are not persisted. They are not value objects, they do not need to protect invariants. They are not commands or events, they carry no semantic meaning in the domain sense. They are pure data containers with types. This simplicity is their strength.


<?php
// src/Dto/Request/CreateOrderRequest.php
// Readonly request DTO with full validation, no mutable state

declare(strict_types=1);

namespace App\Dto\Request;

use App\Dto\Request\Nested\OrderItemRequest;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Request DTO for creating a new order.
 * Validates all input before it reaches the domain layer.
 */
final class CreateOrderRequest
{
    /**
     * @param OrderItemRequest[] $items
     */
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Uuid(versions: [4])]
        public readonly string $customerId,

        #[Assert\NotBlank]
        #[Assert\Count(min: 1, max: 50)]
        #[Assert\Valid]
        public readonly array $items,

        #[Assert\Valid]
        public readonly ?ShippingAddressRequest $shippingAddress = null,

        #[Assert\Choice(choices: ['standard', 'express', 'overnight'])]
        public readonly string $deliveryMethod = 'standard',

        #[Assert\Length(max: 500)]
        public readonly ?string $note = null,
    ) {}
}

3. Request DTOs: type-safe input with validation

A request DTO combines two tasks: deserializing the incoming JSON body into a type-safe PHP object, and validating the input. Both happen before the controller code runs. With the Symfony Serializer, the request body can be deserialized directly into the DTO. With the Symfony Validator, the DTO can then be checked against all constraints. If both succeed, the controller code has the guarantee that the DTO contains valid data, without a single if isset check.

PHP 8.4 readonly properties enforce immutability: once deserialized from the request, the DTO cannot be changed. That prevents a whole class of bugs where request data is unintentionally mutated on its way through the application. Constructor Property Promotion reduces boilerplate to zero, no separate property declarations, no setters, no body assignment in the constructor. The DTO is the constructor. Symfony Validator constraints as attributes directly on the properties ensure that validation rules and data structure never drift apart.

4. Response DTOs: structured output without leakage

Response DTOs define exactly which fields appear in which format in the API response. That is the opposite of the common practice of serializing Doctrine entities directly. When you serialize an entity, all fields appear automatically, including new ones added after the initial release. That is a silent data leak: a field intended for internal tracking suddenly shows up in public API responses because someone extended the entity with a new field without thinking about the API output.

Response DTOs turn the API output into an explicit decision. A new entity field does not automatically appear in the API response, it has to be actively added to the response DTO. That makes breaking changes visible instead of implicit. Different endpoints can use different response DTOs: ProductSummaryResponse for list views with few fields, ProductDetailResponse for detail views with all fields. Symfony Serializer groups solve the same problem with a different approach, DTOs are more explicit and easier to understand.


<?php
// src/Dto/Response/ProductDetailResponse.php
// Explicit response DTO, no field leakage, full IDE support

declare(strict_types=1);

namespace App\Dto\Response;

use DateTimeImmutable;

/**
 * Response DTO for product detail endpoint.
 * Defines exactly which fields are exposed via the API.
 */
final class ProductDetailResponse
{
    public function __construct(
        public readonly string            $id,
        public readonly string            $name,
        public readonly string            $slug,
        public readonly float             $price,
        public readonly string            $currency,
        public readonly string            $type,
        public readonly bool              $inStock,
        public readonly int               $stockQuantity,
        public readonly ?string           $description,
        public readonly array             $tags,
        /** @var ProductImageResponse[] */
        public readonly array             $images,
        public readonly DateTimeImmutable $createdAt,
        public readonly DateTimeImmutable $updatedAt,
    ) {}

    /**
     * Create from domain entity, explicit mapping, no surprise fields.
     */
    public static function fromEntity(Product $product): self
    {
        return new self(
            id:            $product->getId()->toString(),
            name:          $product->getName(),
            slug:          $product->getSlug(),
            price:         $product->getPrice()->getAmount(),
            currency:      $product->getPrice()->getCurrency(),
            type:          $product->getType()->value,
            inStock:       $product->isInStock(),
            stockQuantity: $product->getStockQuantity(),
            description:   $product->getDescription(),
            tags:          $product->getTags(),
            images:        array_map(ProductImageResponse::fromEntity(...), $product->getImages()->toArray()),
            createdAt:     $product->getCreatedAt(),
            updatedAt:     $product->getUpdatedAt(),
        );
    }
}

5. Symfony Serializer: deserialization and serialization

The Symfony Serializer is the heart of the DTO pipeline. To deserialize the incoming request body into a request DTO, you use $serializer->deserialize($json, CreateOrderRequest::class, 'json'). The serializer uses Constructor Property Promotion and respects the type declarations, it automatically attempts to convert JSON values into the declared PHP types. To serialize the response DTO into JSON, you use $serializer->serialize($responseDto, 'json'). The serializer follows the declared types and property names.

For nested DTOs the serializer needs to know which PHP class an array property contains. That happens via the #[ArrayOf] annotation or via Symfony Serializer mappings. For readonly DTOs the serializer context [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false] matters: it rejects JSON fields that are not defined in the DTO, which rules out mass assignment. The PropertyNormalizer or the ObjectNormalizer must be configured correctly to handle readonly properties.

6. The controller: clean thanks to DTOs

With request and response DTOs, the controller becomes what it is meant to be: a thin coordinator. It receives the request DTO (already deserialized and validated), calls the application service, gets back the domain result, maps it to the response DTO and serializes it. No validation logic, no array access, no manual null checks. Every line in the controller has a clear responsibility. That makes controller code testable, readable and maintainable.

For automatic deserialization of the request body into the DTO as a controller argument, you can write a Symfony argument value resolver. It kicks in whenever a controller argument has the type of a request DTO, fetches the request body, deserializes it into the DTO, validates it and throws a ValidationException if there are errors. The result: the controller has no deserialization logic, it simply receives a typed, validated object as a parameter.


<?php
// src/Controller/Api/ProductController.php
// Clean controller with Request-DTO and Response-DTO, no arrays, no noise

declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\Request\CreateProductRequest;
use App\Dto\Response\ProductDetailResponse;
use App\Service\ProductService;
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\SerializerInterface;

#[Route('/api/products')]
final class ProductController extends AbstractController
{
    public function __construct(
        private readonly ProductService      $productService,
        private readonly SerializerInterface $serializer,
    ) {}

    /**
     * Create a new product.
     * Request body is deserialized and validated automatically via ArgumentResolver.
     */
    #[Route('', name: 'api_product_create', methods: ['POST'])]
    public function create(CreateProductRequest $request): JsonResponse
    {
        // DTO is already validated, no manual validation here
        $product = $this->productService->create($request);

        // Explicit mapping to Response-DTO, no field leakage
        $response = ProductDetailResponse::fromEntity($product);

        return new JsonResponse(
            $this->serializer->serialize($response, 'json'),
            Response::HTTP_CREATED,
            ['Content-Type' => 'application/json'],
            true // Already JSON, skip double-encoding
        );
    }

    /**
     * Get product details.
     */
    #[Route('/{id}', name: 'api_product_show', methods: ['GET'])]
    public function show(string $id): JsonResponse
    {
        $product  = $this->productService->findOrFail($id);
        $response = ProductDetailResponse::fromEntity($product);

        return new JsonResponse(
            $this->serializer->serialize($response, 'json'),
            Response::HTTP_OK,
            ['Content-Type' => 'application/json'],
            true
        );
    }
}

7. Arrays vs. DTOs side by side

Criterion Arrays everywhere Request and Response DTOs Winner
Type safety None, everything is mixed Complete, PHP types DTOs
IDE autocompletion Only with PHPDoc hacks Native, no annotation needed DTOs
Validation Manual, scattered Centralized via attributes DTOs
Data leakage High risk with entity serialization No risk, explicit mapping DTOs
Refactoring String-based array keys Property rename via IDE DTOs
Setup effort Minimal One-time, ArgumentResolver Tie

8. Summary

Request DTOs and response DTOs in Symfony REST APIs replace array chaos with type-safe, validated, documentable data containers. PHP 8.4 with readonly properties and Constructor Property Promotion makes DTOs compact, no getters, no setters, no boilerplate. Request DTOs combine deserialization and validation in one step before the controller code runs. Response DTOs explicitly define the API output and prevent data leakage caused by implicit entity serialization.

The one-time effort, an argument value resolver for automatic deserialization and validation, plus the DTO classes themselves, pays off immediately: controllers become minimal, PHPStan finds real errors, IDEs offer full autocompletion, and OpenAPI documentation can be generated from the DTO properties and annotations. In a grown Symfony API, migrating to DTOs is not all-or-nothing, you start with one endpoint and move over gradually.

Request and Response DTOs in Symfony, the essentials at a glance

Request DTOs

readonly final class with Symfony Validator constraints. Automatic deserialization plus validation via ArgumentResolver. No boilerplate in controllers.

Response DTOs

Explicit mapping in a fromEntity() method. No data leak from new entity fields. Different DTOs for list vs. detail views.

PHP 8.4 features

readonly properties for immutability. Constructor Property Promotion for zero boilerplate. Typed properties for full IDE support and PHPStan.

Symfony integration

ArgumentValueResolver for automatic deserialization. Symfony Serializer with ALLOW_EXTRA_ATTRIBUTES => false. Validator with ConstraintViolationList turned into an exception.

9. FAQ: Symfony Request DTOs and Response DTOs

1DTO vs. Doctrine entity: what is the difference?
Entity has a database connection, identity and lifecycle methods. DTO is a pure data container with no persistence, no logic. DTOs transfer data between layers.
2Same DTO for request and response?
Technically possible, practically a bad idea. Request DTOs have validation constraints, response DTOs have computed fields. Separate DTOs make both responsibilities explicit.
3Automatic deserialization via ArgumentResolver?
ArgumentValueResolver checks whether the argument type is a request DTO. Fetches JSON, deserializes, validates, throws a ValidationException on errors. Controller receives the finished, validated DTO.
4Benefit of readonly properties for DTOs?
Immutability after construction. DTO cannot be changed on its way through the application. Clearly communicates that it is a data container.
5Preventing mass assignment with DTOs?
DTO defines only allowed properties. Serializer with ALLOW_EXTRA_ATTRIBUTES => false rejects unknown fields. The DTO is the allowlist.
6Serializer groups or separate DTOs?
Separate DTOs are more explicit and easier to understand. Serializer groups on entities become confusing quickly. API contracts of each endpoint are clearly readable from the code.
7Nested objects in response DTOs?
Dedicated DTOs for nested objects. fromEntity() pattern carries over the hierarchy. Symfony Serializer automatically serializes nested DTOs correctly.
8PHPStan and DTOs?
Yes, with typed properties PHPStan catches incompatible types and non-existent properties. Level 8 analysis with DTOs finds errors that would be invisible with arrays.
9Migrating an existing API to DTOs step by step?
Endpoint by endpoint. ArgumentValueResolver that only kicks in for the Dto\Request namespace. Switch one endpoint, test, continue. Old endpoints stay unchanged.
10DTOs and NelmioApiDocBundle for OpenAPI?
NelmioApiDocBundle reads PHP types from DTO constructors and generates OpenAPI schemas. #[OA\Property] on properties for examples and descriptions. DTO becomes the single source of truth.