Request Mapping in Symfony 7
Anyone still writing manual $request->query->get() and json_decode($request->getContent()) in Symfony controllers is giving up type safety and validation for nothing. MapQueryString and MapRequestPayload have mapped query parameters and request bodies directly into typed DTOs since Symfony 6.3, automatically deserialized, validated, and ready to use.
Table of Contents
- 1. The Problem: Extracting Request Data Manually
- 2. MapQueryString: Query Parameters Directly Into DTOs
- 3. MapRequestPayload: Mapping the JSON Body Type-Safely
- 4. DTOs With PHP 8.4 Constructor Promotion
- 5. Validation With Symfony Constraints
- 6. Error Handling and HTTP Responses
- 7. Nested DTOs and Collections
- 8. Different Formats: JSON, Form Data, XML
- 9. Comparison: Before and After
- 10. Summary
- 11. FAQ
1. The Problem: Extracting Request Data Manually
In classic Symfony controllers, you read query parameters via $request->query->get('page', 1), form data via $request->request->get('name'), and a JSON body via json_decode($request->getContent(), true). All of these accesses return mixed, with no type safety, no automatic validation, and no structured error feedback. A controller that processes five query parameters and a JSON body quickly accumulates ten lines of pure extraction boilerplate before the actual logic even begins.
The problem goes beyond boilerplate. If an integer parameter arrives as a string in the query string, you have to cast it explicitly. If a required field is missing from the JSON body, you have to check for it and return a structured error response. As validation logic grows, it often ends up directly in the controller instead of in dedicated constraint classes. MapQueryString and MapRequestPayload solve exactly this pain point: they externalize extraction, deserialization, and validation into typed PHP classes, leaving only the business logic inside the controller.
2. MapQueryString: Query Parameters Directly Into DTOs
The attribute #[MapQueryString], from the Symfony\Component\HttpKernel\Attribute namespace, tells Symfony to take the request's entire query string, interpret it as a nested array, and deserialize that array into the annotated argument DTO. The Symfony Serializer handles the mapping from query parameter names to DTO properties. Properties missing from the query string receive their PHP default value, null for nullable types, or the declared default value for others. The result is a fully typed PHP object that can be used directly in the controller.
For #[MapQueryString], Symfony accepts both simple values and arrays in query notation: ?tags[]=php&tags[]=symfony is automatically mapped into an array property. Integer values like ?page=2 are converted into int properties, boolean strings like ?active=true into bool. This automatic type conversion works reliably for all scalar types. The argument can be optional, if no query string is present, Symfony passes null and the controller has to account for that. With #[MapQueryString] on a non-nullable DTO type, Symfony returns a default object with the property default values for an empty query string.
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* DTO for product list query parameters, mapped via MapQueryString.
*/
final class ProductListQuery
{
public function __construct(
// Default page is 1, ?page=2 sets this to 2 automatically
#[Assert\Positive]
public readonly int $page = 1,
// Items per page, clamped by constraint to prevent abuse
#[Assert\Range(min: 1, max: 100)]
public readonly int $limit = 20,
// Optional search term, null if absent from query string
#[Assert\Length(max: 255)]
public readonly ?string $search = null,
// Sort field, only allow known columns
#[Assert\Choice(choices: ['name', 'price', 'createdAt'])]
public readonly string $sort = 'createdAt',
// Sort direction
#[Assert\Choice(choices: ['asc', 'desc'])]
public readonly string $direction = 'desc',
// Tags array, ?tags[]=php&tags[]=symfony
/** @var string[] */
public readonly array $tags = [],
) {}
}
In the controller itself, the mapping is invisible, the attribute handles everything automatically:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\ProductListQuery;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
final class ProductController extends AbstractController
{
public function __construct(
private readonly ProductRepository $productRepository,
) {}
/**
* List products with filtering, sorting and pagination.
* Query params are automatically mapped and validated via MapQueryString.
*/
#[Route('/api/products', methods: ['GET'])]
public function list(#[MapQueryString] ProductListQuery $query): JsonResponse
{
// $query is already typed, validated, and ready to use, no manual extraction
$products = $this->productRepository->findByQuery($query);
return $this->json([
'data' => $products,
'page' => $query->page,
'limit' => $query->limit,
]);
}
}
3. MapRequestPayload: Mapping the JSON Body Type-Safely
The attribute #[MapRequestPayload] works analogously to #[MapQueryString], but operates on the request body: Symfony reads the body, detects the content type format (JSON, form data, or XML), deserializes it with the Symfony Serializer into the annotated DTO, and then runs Symfony validation. For an invalid body, Symfony automatically returns a 422 Unprocessable Entity response with structured validation errors, without a single line of manual error handling in the controller.
One critical difference from #[MapQueryString]: #[MapRequestPayload] fails with a BadRequestHttpException if the body is missing or cannot be parsed. This is the correct behavior for POST endpoints where a body is expected. For optional bodies, for instance on PATCH endpoints that only update submitted fields, there is the parameter validationFailedStatusCode: 0, which disables the automatic error response and instead makes a validator ConstraintViolationList accessible in the method. That way, MapRequestPayload remains usable for partial-update scenarios too.
4. DTOs With PHP 8.4 Constructor Promotion
PHP 8.4 constructor property promotion makes DTOs for MapQueryString and MapRequestPayload especially compact. All properties are declared, typed, and annotated with validation constraints directly in the constructor, in a single class without separate getter boilerplate. Readonly properties prevent accidental mutation after deserialization. The DTO thus becomes a fully immutable value object that represents the state of a valid request.
Symfony 7 also supports union types and nullable properties for MapRequestPayload DTOs. A field like public readonly int|string|null $identifier = null is deserialized correctly if the serializer can infer the type from the JSON value. For more complex scenarios, for instance when a field can be either a string array or a single string, a custom normalizer that tells the Symfony Serializer how the mapping should work is recommended. For most practical API DTOs, though, simple readonly properties with scalar types are entirely sufficient.
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* DTO for creating a new product, mapped via MapRequestPayload from JSON body.
*/
final class CreateProductInput
{
public function __construct(
// Required string, 422 if missing or blank
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
public readonly string $name,
// Positive decimal as string, avoids float precision issues
#[Assert\NotBlank]
#[Assert\Positive]
public readonly string $price,
// Optional description with length limit
#[Assert\Length(max: 5000)]
public readonly ?string $description = null,
// Nested category input, validated recursively via Valid constraint
#[Assert\NotNull]
#[Assert\Valid]
public readonly ?CategoryInput $category = null,
// Array of tag strings, each validated individually
/** @var string[] */
#[Assert\All([new Assert\NotBlank(), new Assert\Length(max: 50)])]
public readonly array $tags = [],
) {}
}
/**
* Nested DTO for category, used inside CreateProductInput.
*/
final class CategoryInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Positive]
public readonly int $id,
) {}
}
5. Validation With Symfony Constraints
The biggest advantage of MapQueryString and MapRequestPayload over manual extraction lies in automatic validation. After deserialization, Symfony automatically runs the validator on the mapped object. All constraints on the DTO properties, NotBlank, Length, Range, Choice, Email, Url, Valid for nested objects, are checked before the controller code even runs. On validation errors, Symfony automatically returns a 422 Unprocessable Entity response whose body contains a structured list of violations.
Complex, cross-field validations are implemented as class-level constraints or as custom validator classes. A custom constraint like #[Assert\Callback] on the DTO receives the entire object and the ExecutionContextInterface, and can add validation violations programmatically. The cascade attribute #[Assert\Valid] on a nested DTO property triggers validation of the nested object, so errors in CategoryInput are reported exactly like errors in the root DTO. Validation groups via validationGroups: ['Default', 'create'] on the attribute allow different validation rules for create and update operations without separate DTO classes.
6. Error Handling and HTTP Responses
When MapRequestPayload detects a validation error, an HttpException with status 422 is thrown by default. In an API context with the Symfony Serializer and the Problem Details format (RFC 7807) enabled, Symfony delivers a structured JSON response with a violations field describing each error with a path, message, and code. This response is directly interpretable for API clients, no custom exception handling in the controller needed.
For cases where you want to customize the error response, for instance to return your own error codes or a different format, you register a custom ExceptionListener or a KernelExceptionEvent subscriber. The subscriber catches HttpException with status 422 and transforms the ConstraintViolationList into the desired format. For even more control, set validationFailedStatusCode: 0 on the attribute, then Symfony does not throw an exception but instead passes the object with its violations directly. In this mode, you receive the ConstraintViolationList in the controller as a separate argument and can decide yourself how to respond.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CreateProductInput;
use App\Service\ProductService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
final class ProductController extends AbstractController
{
public function __construct(
private readonly ProductService $productService,
) {}
/**
* Create a new product from JSON request body.
* MapRequestPayload handles deserialization and validation automatically.
* Returns 422 with violation details on invalid input, no manual try/catch needed.
*/
#[Route('/api/products', methods: ['POST'])]
public function create(
#[MapRequestPayload] CreateProductInput $input,
): JsonResponse {
// At this point, $input is guaranteed valid, constraints have been checked
$product = $this->productService->createProduct($input);
return $this->json($product, Response::HTTP_CREATED);
}
}
7. Nested DTOs and Collections
Both MapQueryString and MapRequestPayload support nested objects. With MapRequestPayload, the Symfony Serializer automatically deserializes deeply nested JSON structures if the properties are typed correctly. A property of type CategoryInput gets populated from the corresponding JSON object, and an array property /** @var LineItemInput[] */ with the correct PHPDoc type gets deserialized as a collection. Symfony 7 evaluates PHPDoc types for array elements via the PropertyInfoExtractor, the annotation /** @var LineItemInput[] */ is enough to determine the type of the array elements at deserialization time.
For MapQueryString, arrays work via the standard query string notation: ?items[0][quantity]=2&items[0][productId]=5 gets mapped into an array of nested objects. This is unwieldy for complex query strings, in practice, a JSON body via MapRequestPayload is recommended for nested data. Simple arrays like ?tags[]=php&tags[]=symfony, on the other hand, are handled well with MapQueryString too, and are frequently used for filter lists on GET endpoints. The validator checks array elements with #[Assert\All([...])], which validates every entry with the contained constraints.
8. Different Formats: JSON, Form Data, XML
MapRequestPayload detects the request format automatically based on the Content-Type header. For application/json, Symfony uses the JSON serializer, for application/x-www-form-urlencoded or multipart/form-data the form component, for application/xml the XML serializer. The same DTO thus works for JSON APIs and form endpoints without any change. In practice, using exclusively JSON as the input format for REST APIs is recommended, that reduces complexity and makes the behavior predictable.
Anyone who wants to control the format explicitly sets the parameter format: 'json' on the #[MapRequestPayload] attribute. This forces JSON deserialization regardless of the content type header, and prevents clients from unexpectedly steering the format. For endpoints that should accept both JSON and form data, for instance for legacy clients, you leave the format parameter out and rely on automatic detection. In that case, the DTO has to ensure all properties get populated correctly via form data names too, Symfony uses property names as form field names for this, which fits in most cases.
9. Comparison: Before and After
The difference between manual request handling and MapQueryString/MapRequestPayload shows up most clearly in a direct code comparison. The following table contrasts typical tasks.
| Task | Manual | With MapQueryString / MapRequestPayload | Benefit |
|---|---|---|---|
| Reading query parameters | $request->query->get('page', 1) |
$query->page (typed) |
Type-safe, no cast needed |
| Deserializing JSON body | json_decode(..., true)['name'] |
$input->name (DTO property) |
Structured, no array access |
| Validation | Manual in controller or service | Automatic via constraints | 422 response without controller code |
| Error response | Manual JSON error building | Automatically structured | RFC 7807 Problem Details included |
| Nested objects | Manual nested-array handling | Automatic DTO deserialization | Typed all the way down |
The savings are measurable: a typical CRUD controller with five query parameters and a JSON body ends up with roughly 30% fewer lines in the controller code when using MapQueryString and MapRequestPayload. More important than the line count is the qualitative improvement: all request structure assumptions are explicitly documented in DTO classes, and the IDE finds every usage of a field through normal PHP navigation, no more string access on arrays.
Mironsoft
Symfony API development, clean architecture, and PHP 8.4 best practices
Ready to modernize your Symfony controllers with MapQueryString and MapRequestPayload?
We refactor existing Symfony APIs to typed DTOs, eliminate request boilerplate, and introduce structured validation and error handling, for more maintainable controllers and better API quality.
DTO design
Typed request DTOs with PHP 8.4 constructor promotion and Symfony constraints
Controller refactoring
Migration from manual request extraction to MapQueryString and MapRequestPayload
API quality
Structured error responses per RFC 7807 and complete OpenAPI documentation
10. Summary
MapQueryString and MapRequestPayload have been the recommended way to process request data in controllers since Symfony 6.3. Both attributes eliminate manual extraction boilerplate, enforce type safety through typed DTOs, and automatically run Symfony validation, before the controller code even executes. The result is leaner controllers, more explicit API contracts, and structured error responses without custom exception handling.
For new Symfony 7 projects, no controller should access $request->query->get() or json_decode($request->getContent()) directly anymore. Instead, every controller method encapsulates its input in a DTO, annotated with #[MapQueryString] for GET parameters or #[MapRequestPayload] for body data. The DTOs document the API contract themselves, are testable without an HTTP request, and form a clean boundary between infrastructure and domain.
MapQueryString and MapRequestPayload: The Essentials at a Glance
MapQueryString
Maps query parameters automatically into typed DTOs. Scalar conversion, array notation, and Symfony validation included, no more $request->query->get().
MapRequestPayload
Deserializes JSON, form data, and XML into DTOs. On validation errors, automatically 422 with structured violations, no manual error handling needed.
DTO design
PHP 8.4 constructor property promotion with readonly properties. Constraints directly on properties, immutable, testable, IDE-navigable.
Nesting
Nested DTOs and collections get deserialized automatically. #[Assert\Valid] cascades validation into nested objects.