MapQueryString and MapRequestPayload: Request Mapping in Symfony 7
AI generated
SF
{ }
Symfony · PHP 8.4 · Request Mapping · DTOs
MapQueryString and MapRequestPayload
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.

15 min read MapQueryString · MapRequestPayload · DTO · Validation · PHP 8.4 Symfony 6.3+ · 7.x · PHP 8.2+

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.

11. FAQ: MapQueryString and MapRequestPayload in Symfony

1What is MapQueryString in Symfony?
A PHP attribute from Symfony 6.3+ that automatically deserializes query parameters into a typed DTO, no more manual $request->query->get(), automatic validation included.
2What is MapRequestPayload?
Maps a JSON, form data, or XML body into a typed DTO. On validation errors, automatically 422 with structured violations, no manual error handling in the controller.
3From which Symfony version is it available?
Since Symfony 6.3, included in all Symfony 7.x versions. Usable immediately with PHP 8.2+.
4What happens on a validation error?
Automatically HTTP 422 with a structured violations list. Controller code does not run. Can be disabled with validationFailedStatusCode: 0 for manual processing.
5Is form data possible with MapRequestPayload?
Yes. Content type gets detected automatically, JSON, form data, and XML are supported. The same DTO works without changes.
6Validating nested DTOs?
#[Assert\Valid] on the property automatically cascades validation into nested DTOs. Errors get reported with the correct path in the violation list.
7Difference from $request->query->get()?
$request->query->get() returns mixed. MapQueryString deserializes into the declared PHP type, validates automatically, and provides a typed DTO, IDE-navigable, no cast needed.
8Combine both attributes in one method?
Yes. MapQueryString and MapRequestPayload can be combined in the same controller method, query string and body get mapped independently.
9What DTO structure is expected?
Any PHP class with typed properties, constructor property promotion with readonly is recommended. Property names must match query parameters or JSON keys.
10How do you test controllers with these attributes?
Send the request in WebTestCase with correct query parameters or a JSON body, Symfony maps as in production. Unit-test the DTO itself via Validator::validate() without an HTTP request.