Argument Resolution

Symfony reads your controller's signature and fills each argument from the route, the request, the body or the container.

Syntaxpublic function action(string $routeParam, #[MapRequestPayload] MyDto $body, Request $request): Response

You never call your controller yourself, so you never pass it arguments. Symfony reads the method signature — each parameter's name, type and default — and works out a value for every one. The pieces that do this are called argument value resolvers. They run in order; the first one that can produce a value wins.

Where each argument comes from

  • By name: a parameter whose name matches a route placeholder gets that value, cast to the declared int, string, backed enum or UID.
  • By type: a Request parameter gets the current request; a SessionInterface parameter gets the session.
  • By service: any other class or interface type is looked up as a service — an object the framework builds and hands you. Stage 2 covers how that works.
  • By attribute: #[MapRequestPayload] builds an object from the body, #[MapQueryString] from the whole query string, #[MapQueryParameter] from one query parameter. All three arrived in Symfony 6.3. #[MapUploadedFile] arrived in 7.1.
  • By default: if nothing resolves it and the parameter has a default, the default is used.

What trips people up

A route placeholder and the parameter must share a name exactly; {articleId} does not fill $id. The mapping attributes fail loudly rather than silently: a missing #[MapQueryParameter] produces 404, and a payload that fails validation produces 422. Both are configurable through validationFailedStatusCode.

If an argument cannot be resolved at all, Symfony throws before your code runs, and the message names the argument.

Example

Example · php
// src/Controller/SubscriptionController.php
declare(strict_types=1);

namespace App\Controller;

use App\Dto\SubscribeRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

final class SubscriptionController extends AbstractController
{
    #[Route('/lists/{listId}/subscribers', name: 'app_subscribe', methods: ['POST'])]
    public function subscribe(
        // Name matches the route placeholder.
        string $listId,
        // Built from the JSON or form body, then validated.
        #[MapRequestPayload] SubscribeRequest $payload,
        // One query parameter; the default keeps it optional.
        #[MapQueryParameter] bool $notify = false,
        // Resolved by type, not by name.
        ?Request $request = null,
    ): JsonResponse {
        return $this->json([
            'list' => $listId,
            'email' => $payload->email,
            'notify' => $notify,
            'method' => $request?->getMethod(),
        ], Response::HTTP_ACCEPTED);
    }
}

When to use it

  • Receive a validated JSON body as a typed object instead of an array.
  • Take a page number from the query string with a sane default and a 400 on nonsense.
  • Accept an entity id from the URL already cast to int or to a backed enum.
  • Work out why Symfony says it cannot resolve an argument of your controller.

More examples

The payload object itself

A plain PHP class with constraint attributes. #[MapRequestPayload] deserialises the request body into it and runs the validator before your controller is entered, so by the time you read $payload->email it is a non-empty, well-formed address. Keep one such class per endpoint shape: it documents the accepted body and removes every manual isset check from the controller.

Example · php
// src/Dto/SubscribeRequest.php
declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class SubscribeRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Email]
        public readonly string $email = '',

        #[Assert\Choice(choices: ['daily', 'weekly'])]
        public readonly string $frequency = 'weekly',
    ) {
    }
}

The whole query string as one object

GET /articles?page=3&perPage=50 returns 200 with those values; GET /articles?page=-1 returns 400 because the constraints on ArticleFilter rejected it. Reach for #[MapQueryString] once an endpoint has more than two or three filters, so the rules live on one reusable class instead of being re-derived in every action.

Example · php
// src/Controller/ArticleListController.php
declare(strict_types=1);

namespace App\Controller;

use App\Dto\ArticleFilter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleListController extends AbstractController
{
    #[Route('/articles', name: 'app_article_index', methods: ['GET'])]
    public function index(
        // A default instance makes an empty query string valid.
        #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
        ArticleFilter $filter = new ArticleFilter(),
    ): JsonResponse {
        return $this->json([
            'page' => $filter->page,
            'perPage' => $filter->perPage,
            'tag' => $filter->tag,
        ]);
    }
}

The same action without any resolver help

This answers identically to the previous example. Seeing both is the point: the attributes are not magic, they are this code moved into a resolver and a class of constraints. The hand-written version is worth knowing because you will meet it in older projects, and because it shows exactly which checks you lose if you take the attribute off.

Example · php
// src/Controller/ArticleListLonghandController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleListLonghandController extends AbstractController
{
    #[Route('/articles/longhand', name: 'app_article_index_longhand', methods: ['GET'])]
    public function index(Request $request): JsonResponse
    {
        // Everything the attributes did, written by hand.
        $page = $request->query->getInt('page', 1);
        $perPage = $request->query->getInt('perPage', 20);
        $tag = $request->query->getString('tag');

        if ($page < 1 || $perPage < 1 || $perPage > 100) {
            return $this->json(
                ['error' => 'page and perPage are out of range'],
                Response::HTTP_BAD_REQUEST,
            );
        }

        return $this->json(['page' => $page, 'perPage' => $perPage, 'tag' => $tag]);
    }
}

Discussion

  • Be the first to comment on this lesson.