Controller, DTOs, Serializer, Validator and Error Handling
A production-ready Symfony REST API is not born from plugging individual components together, it requires a clear architecture that connects all the pieces consistently: request deserialization, validation, business logic, response serialization and unified error handling. This guide shows the complete setup with Symfony 7 and PHP 8.4.
Table of Contents
- 1. The architecture of a production-ready Symfony REST API
- 2. Project setup: packages and configuration
- 3. Controller: thin, coordinating, type-safe
- 4. ArgumentValueResolver: automatic deserialization and validation
- 5. Application service: business logic without HTTP dependencies
- 6. Centralized error handling: exception listener and problem details
- 7. Comparing architectural approaches
- 8. Summary
- 9. FAQ
1. The architecture of a production-ready Symfony REST API
A production-ready Symfony REST API consists of several clearly separated layers. The controller is the entry point for HTTP requests. It coordinates but contains no business logic. It receives typed input DTOs and returns typed response DTOs. The application service contains the application logic, it orchestrates domain objects, repositories and external services. It has no knowledge of HTTP, JSON or Symfony requests. The domain layer contains entities, value objects and domain services with pure business rules. The infrastructure layer contains Doctrine repositories, external API clients and queue implementations.
Between controller and application service sit the DTOs as type-safe data containers. The ArgumentValueResolver takes care of deserializing the request body into a request DTO and of validation. The exception listener translates all unhandled exceptions into structured problem details responses. The result is an API where every layer has clear responsibilities, no HTTP code leaks into the domain, and errors are handled uniformly, no matter which layer they come from.
2. Project setup: packages and configuration
A Symfony REST API needs a manageable set of packages. The Symfony skeleton is the right starting point, not the website skeleton, which brings unnecessary frontend dependencies. The core packages: symfony/serializer for deserialization and serialization, symfony/validator for input validation, doctrine/doctrine-bundle and doctrine/orm for database access, lexik/jwt-authentication-bundle for JWT authentication, and nelmio/api-doc-bundle for OpenAPI documentation. For development, symfony/maker-bundle and phpstan/phpstan are added.
The Symfony Serializer configuration must be explicitly configured for readonly properties and constructor property promotion. The serializer.mapping path and the property_info component must be active. For deserialization into DTOs you need the PropertyNormalizer or ObjectNormalizer configured correctly. The error configuration in config/packages/framework.yaml must set error_controller: false for API endpoints so that no HTML error pages are returned, the custom exception listener takes over control.
# Project setup, Symfony REST API from scratch
composer create-project symfony/skeleton mironsoft-api
cd mironsoft-api
# Core API packages
composer require symfony/serializer \
symfony/validator \
symfony/property-info \
doctrine/doctrine-bundle \
doctrine/orm \
symfony/uid
# Authentication
composer require lexik/jwt-authentication-bundle
composer require symfony/rate-limiter
# API Documentation
composer require nelmio/api-doc-bundle
# Dev tools
composer require --dev symfony/maker-bundle \
phpstan/phpstan \
phpstan/extension-installer \
phpunit/phpunit \
symfony/phpunit-bridge
# Generate JWT keys
php bin/console lexik:jwt:generate-keypair
3. Controller: thin, coordinating, type-safe
The Symfony controller in a REST API has a single responsibility: coordination. It receives the already deserialized and validated request DTO, calls the application service, takes the result, maps it to a response DTO and serializes it. No database queries, no validation logic, no business rules. A controller that follows these principles typically has 10 to 20 lines of code per action method and is trivially testable, you instantiate the service with a mock and check the result.
In Symfony 7 with PHP 8.4, controller attributes are the standard. The #[Route] attribute directly on the class defines the URL prefix, and on the method the specific route, HTTP method and name. Controllers should be final, inheriting from controllers is an anti-pattern. Autowiring via constructor property promotion works for all required services. Response serialization happens via $this->serializer->serialize($dto, 'json') with the appropriate content type header.
4. ArgumentValueResolver: automatic deserialization and validation
The ArgumentValueResolver is the heart of automatic request DTO processing. It kicks in when a controller argument has a type that comes from a marker interface or from a defined namespace. The resolver takes the JSON body from the request, deserializes it into the desired DTO, validates it with the Symfony Validator and throws a ValidationException with the full ConstraintViolationList if errors are present. The controller sees none of this, it receives the finished, valid DTO.
The exception listener catches the ValidationException and builds a structured problem details response with the errors array from it. Each violation is converted into a field error object: field path from $violation->getPropertyPath(), error message from $violation->getMessage(), rejected value from $violation->getInvalidValue(). The result is a consistent pattern: request in, validation errors as problem details out, or a valid DTO into the controller.
<?php
// src/ArgumentResolver/RequestDtoResolver.php
// Automatic Request-DTO deserialization and validation
declare(strict_types=1);
namespace App\ArgumentResolver;
use App\Dto\Request\RequestDtoInterface;
use App\Exception\ValidationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RequestDtoResolver implements ValueResolverInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ValidatorInterface $validator,
) {}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$type = $argument->getType();
if (!is_string($type) || !is_a($type, RequestDtoInterface::class, true)) {
return [];
}
$content = $request->getContent();
if (empty($content)) {
return [new $type()]; // Use defaults for empty body
}
try {
$dto = $this->serializer->deserialize(
$content,
$type,
'json',
['allow_extra_attributes' => false]
);
} catch (NotNormalizableValueException $e) {
throw new ValidationException(
"Request body cannot be deserialized: {$e->getMessage()}"
);
}
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
throw ValidationException::fromViolationList($violations);
}
return [$dto];
}
}
5. Application service: business logic without HTTP dependencies
The application service is the layer between controller and domain. It receives request DTOs, orchestrates domain objects and repositories, and returns domain results that the controller maps into response DTOs. The application service has no dependencies on Symfony's HttpFoundation component, no Request object, no Response objects, no session. That makes it independent of the HTTP layer and directly usable from command line commands, message queue handlers or tests.
In PHP 8.4 you use constructor property promotion for all dependencies of the application service. Transaction boundaries are defined in the service, either via Doctrine's $this->entityManager->wrapInTransaction() or via the #[Transactional] attribute with the Doctrine Extensions Bundle. Domain exceptions are not caught in the service, they propagate upward to the exception listener. Only expected technical errors (e.g. external API timeout) are caught and converted into domain exceptions. That keeps the application service methods compact and error handling centralized.
<?php
// src/Service/ProductService.php
// Application Service, no HTTP dependencies, orchestrates domain
declare(strict_types=1);
namespace App\Service;
use App\Dto\Request\CreateProductRequest;
use App\Entity\Product;
use App\Exception\DuplicateSkuException;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
final class ProductService
{
public function __construct(
private readonly ProductRepository $productRepository,
private readonly EntityManagerInterface $entityManager,
) {}
/**
* Create a new product from a validated request DTO.
*
* @throws DuplicateSkuException If a product with the same SKU already exists.
*/
public function create(CreateProductRequest $request): Product
{
// Domain invariant check via repository, not in controller
if ($this->productRepository->existsBySku($request->sku)) {
throw new DuplicateSkuException($request->sku);
}
$product = Product::create(
name: $request->name,
sku: $request->sku,
price: $request->price,
currency: $request->currency,
type: ProductType::from($request->type),
description: $request->description,
);
foreach ($request->tags as $tag) {
$product->addTag($tag);
}
$this->entityManager->persist($product);
$this->entityManager->flush();
return $product;
}
/**
* Find a product by ID or throw a not-found exception.
*
* @throws ProductNotFoundException If no product with the given ID exists.
*/
public function findOrFail(string $id): Product
{
$product = $this->productRepository->find($id);
if ($product === null) {
throw ProductNotFoundException::forId($id);
}
return $product;
}
}
6. Centralized error handling: exception listener and problem details
Centralized error handling means that no single controller method needs try/catch blocks, all exceptions propagate upward to the kernel.exception event subscriber. This checks the request accept header, identifies the exception type and translates it into an RFC 9457 compliant problem details response. Domain exceptions, validation errors, 404 errors and unexpected system errors are all handled in the same listener, consistently, with a correlation ID, with the correct HTTP status code.
The exception hierarchy is important: all domain exceptions inherit from App\Exception\DomainException, which carries an ApiErrorCode as a property. Validation errors are thrown via ValidationException with a ConstraintViolationList. Symfony's own exceptions (AccessDeniedException, NotFoundHttpException) are also caught in the listener and mapped to problem details. Only for system exceptions (a catch-all \Throwable) is a generic HTTP 500 response returned without internal details. The stack trace and the original exception message appear only in the log, never in the API response.
| Exception type | HTTP status | Problem details code | Internal details exposed |
|---|---|---|---|
| ValidationException | 422 | VALIDATION_FAILED + errors array | No |
| DomainException | 422 / 409 / 404 | ApiErrorCode value | No |
| AccessDeniedException | 403 | ACCESS_DENIED | No |
| NotFoundHttpException | 404 | RESOURCE_NOT_FOUND | No |
| \Throwable (all others) | 500 | INTERNAL_ERROR | Never, only a correlation ID |
8. Summary
A production-ready Symfony REST API with PHP 8.4 and Symfony 7 emerges from clear layers and clear responsibilities. Controllers coordinate without business logic. Request DTOs as type-safe input objects are automatically deserialized and validated via the ArgumentValueResolver. Application services orchestrate domain objects without HTTP dependencies. Response DTOs explicitly define the API output without data leakage through entity serialization. The central exception listener translates all exceptions into RFC 9457 compliant problem details responses with correlation IDs.
The pattern is not new, it applies well-known clean architecture principles in a concrete Symfony implementation. Every part is individually testable: controller with a mocked service, application service with a mocked repository, exception listener with synthetic exceptions. PHPStan at level 8 finds errors through the consistent typing that would be invisible without DTOs. The result is an API codebase that scales with growing requirements without becoming maintenance debt.
Building a Symfony REST API from scratch, the essentials at a glance
Layered architecture
Controller coordinates. Application service contains business logic. Domain is HTTP-free. Infrastructure contains DB and external clients. No layer violates the next.
ArgumentValueResolver
Automatic deserialization plus validation before the controller. Controller only ever receives valid DTOs. ValidationException propagates to the error listener.
Exception listener
No try/catch in controllers. All exceptions land in the kernel.exception listener. RFC 9457 problem details with correlation ID for every error type.
PHP 8.4 features
readonly DTOs with constructor property promotion. Typed properties for PHPStan level 8. Enums for ApiErrorCodes and domain status values.