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.
Table of Contents
- 1. The array problem in Symfony REST APIs
- 2. What DTOs are and what they are not
- 3. Request DTOs: type-safe input with validation
- 4. Response DTOs: structured output without leakage
- 5. Symfony Serializer: deserialization and serialization
- 6. The controller: clean thanks to DTOs
- 7. Arrays vs. DTOs side by side
- 8. Summary
- 9. FAQ
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.