Category: Development

Clean Controller Design in Symfony with Argument Resolvers

Author(s)

Kevin
Kevin
Symfony provides a mechanism to converters request parameters to objects.
For example, if you have a route defined as /messages/{id} and a controller like this:
#[Route('/messages/{id}')]
public function show(Message $message): Response
{
  // ...
}
Symfony automatically converts the {id} parameter from the URL into a Message entity Object using the built-in EntityValueResolver
This process of transforming a primitive value into a complex object is called argument resolving.

💾 Old Ages

Before Symfony 6.2, we had to rely on the SensioFrameworkExtraBundle and the @ParamConverter annotation achieve similar functionality. The EntityValueResolver was introduced in Symfony 6.2, which led to the deprecation of the SensioFrameworkExtraBundle. For more about the older approach, see this excellent article.

👷How does it work?

When a request comes in, Symfony’s argument resolution system kicks into action behind the scenes. Here’s how it works internally:
  • For each argument, Symfony iterates through all registered argument resolvers in order of priority
  • Each resolver’s supports() method is called to check if it can handle the current argument
  • Once a supporting resolver is found, its resolve() method is called to transform the raw value into the expected object
  • The resolved argument is then passed to the controller action
  • If no resolver supports an argument, Symfony throws an exception

    Built-in Value Resolvers

    Symfony comes with several built-in value resolvers, each handling different types of arguments:
    1. RequestPayloadValueResolver — Maps the request payload or the query string into the type-hinted object.
    2. RequestAttributeValueResolver — Attempts to find a request attribute that matches the name of the argument.
    3. DateTimeValueResolver — Attempts to find a request attribute that matches a DateTime format.
    4. RequestValueResolver — Injects the entire Request object when requested
    5. ServiceValueResolver — Injects a service.
    6. SessionValueResolver — Provides the session object.
    7. DefaultValueResolver — Handles parameters with default values.
    8. UidValueResolver — Convert any UID values from a route path parameter into UID objects
    9. VariadicValueResolver — Verifies if the request data is an array and will add all of them to the argument list.
    10. UserValueResolver — Resolves the current logged in user.
    11. SecurityTokenValueResolver — Resolves the current logged in token
    12. EntityValueResolver — Automatically fetches entities from the database based on the ID in the request (replaces ParamConverter)
    13. BackedEnumValueResolver — Resolves PHP 8.1 backed enums from request attributes
      Each resolver specializes in handling specific argument types, making Symfony controllers clean and focused. For full details on each resolver, check the official documentation.

      🚀 Real-World Example

      Let’s look at a real-world example from the Respawwwn project. Respawwwn.com is a Symfony daily gaming challenge platform inspired by Wordle and GeoGuessr, where players identify games from videos, screenshots, 360° panoramas, or iconic sounds.
      The platform has different types of game sessions (Daily, Survival, Collection, Party), but we wanted a single endpoint to handle session completion for any type/api/game-sessions/{id}/complete
      Instead of creating separate endpoints or complex conditional logic in our controller, we implemented a custom argument resolver to seamlessly handle different session types, something that the built-in EntityValueResolver cannot handle.

      ⚙️ How to create a Custom Argument Resolver

      As described in the documentation we need to create a class that implements the Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface and register this class as a service (controller.argument_value_resolver). We could also implement them using PHP Attributes #[AsTargetedValueResolver] &#[ValueResolver] ) as describe in this article of Symfony 6.3.
      The interface forces us to implement the two methods supports and resolve. When the argument resolver is registered and a controller action with a parameter is called, Symfony will go through all argument resolvers and check which one supports the parameter in question. If one is supported the resolve method will be called and the expected value will be passed on to the called action as parameter.

      Creating a Custom Value Resolver

      Let’s examine the implementation of our GameSessionValueResolver:
      <?php
      
      namespace App\ValueResolver;
      
      use App\Entity\GameSessions\GameSessionInterface;
      use App\Repository\GameSessions\DailyGameSessionRepository;
      use App\Repository\GameSessions\SurvivalGameSessionRepository;
      use Symfony\Component\HttpFoundation\Request;
      use Symfony\Component\HttpKernel\Attribute\AsTargetedValueResolver;
      use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
      use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
      
      #[AsTargetedValueResolver('game_session')]
      class GameSessionValueResolver implements ValueResolverInterface
      {
          /**
           * @var array of repositories that can resolve game sessions
           */
          private array $repositories;
      
          public function __construct(
              DailyGameSessionRepository $dailyGameSessionRepository,
              SurvivalGameSessionRepository $survivalGameSessionRepository,
          ) {
              $this->repositories = [
                  $dailyGameSessionRepository,
                  $survivalGameSessionRepository,
              ];
          }
      
          /**
           * @return GameSessionInterface[]
           */
          public function resolve(Request $request, ArgumentMetadata $argument): iterable
          {
              $argumentType = $argument->getType();
      
              if (GameSessionInterface::class !== $argumentType) {
                  return [];
              }
      
              // Get the value from the request, based on the argument name
              $value = $request->attributes->get('id');
      
              // Try to find a game session from each repository
              foreach ($this->repositories as $repository) {
                  $gameSession = $repository->find($value);
      
                  if ($gameSession instanceof GameSessionInterface) {
                      return [$gameSession];
                  }
              }
      
              return [];
          }
      }
      This resolver does the following:
      1. It implements ValueResolverInterface and is tagged with #[AsTargetedValueResolver('game_session')] to register it as a targeted value resolver in Symfony 6.3+
      2. The resolve() method checks if the argument type matches our interface
      3. It then tries to find a matching game session in each repository
      4. Once found, it returns the session, allowing our controller to use it directly

        Using the Custom Resolver in Controllers

        With this resolver in place, our controller can now be extremely clean:
        /**
         * @Route("/api/game-sessions/{id}/complete", name="api_game_session_complete")
         */
        public function completeGameSession(
            #[ValueResolver('game_session')] GameSessionInterface $gameSession
        ): JsonResponse
        {
            // Logic to complete the game session, regardless of its specific type.
            $this->gameSessionManager->completeSession($gameSession);
            
            return new JsonResponse(['status' => 'completed']);
        }
        Notice how we use the #[ValueResolver('game_session')] attribute to specify which resolver should handle this argument. The controller doesn't need to know which specific type of game session it's dealing with - that's all handled by our resolver.

        🍒 Cherry on Top - Testing Value Resolvers

        Testing custom value resolvers is straightforward. Here’s how we test our GameSessionValueResolver:
        <?php
        
        namespace App\Tests\ValueResolver;
        
        use App\Entity\GameSessions\DailyGameSession;
        use App\Entity\GameSessions\GameSessionInterface;
        use App\Entity\GameSessions\SurvivalGameSession;
        use App\Repository\GameSessions\DailyGameSessionRepository;
        use App\Repository\GameSessions\SurvivalGameSessionRepository;
        use App\ValueResolver\GameSessionValueResolver;
        use PHPUnit\Framework\Attributes\CoversClass;
        use PHPUnit\Framework\Attributes\CoversMethod;
        use PHPUnit\Framework\Attributes\Group;
        use PHPUnit\Framework\TestCase;
        use Symfony\Component\HttpFoundation\Request;
        use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
        use Symfony\Component\Uid\Ulid;
        
        #[CoversClass(GameSessionValueResolver::class)]
        #[Group('respawwwn')]
        #[CoversMethod(GameSessionValueResolver::class, 'resolve')]
        class GameSessionValueResolverTest extends TestCase
        {
            private DailyGameSessionRepository $dailyGameSessionRepository;
        
            private SurvivalGameSessionRepository $survivalGameSessionRepository;
        
            public function setup(): void
            {
                $this->dailyGameSessionRepository = $this->createMock(DailyGameSessionRepository::class);
                $this->survivalGameSessionRepository = $this->createMock(SurvivalGameSessionRepository::class);
            }
        
            public function testWithoutArgumentType(): void
            {
                $request = new Request();
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn(null);
                $argument->expects(self::never())
                    ->method('getName');
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                self::assertSame([], $gameSessionValueResolver->resolve($request, $argument));
            }
        
            public function testNoneGameSessionArgumentType(): void
            {
                $request = new Request();
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn('Foobar');
                $argument->expects(self::never())
                    ->method('getName');
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                self::assertSame([], $gameSessionValueResolver->resolve($request, $argument));
            }
        
            public function testResolveWithMissingId(): void
            {
                $request = new Request(attributes: ['id' => null]);
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn(GameSessionInterface::class);
        
                $this->dailyGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with(null)
                    ->willReturn(null);
        
                $this->survivalGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with(null)
                    ->willReturn(null);
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                self::assertSame([], $gameSessionValueResolver->resolve($request, $argument));
            }
        
            public function testResolveWithDailyGameSession(): void
            {
                $gameSessionId = new Ulid();
                $request = new Request(attributes: ['id' => $gameSessionId]);
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn(GameSessionInterface::class);
        
                $dailyGameSession = $this->createMock(DailyGameSession::class);
        
                $this->dailyGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with($gameSessionId)
                    ->willReturn($dailyGameSession);
        
                $this->survivalGameSessionRepository->expects(self::never())
                    ->method('find');
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                $result = $gameSessionValueResolver->resolve($request, $argument);
        
                self::assertCount(1, $result);
                self::assertSame($dailyGameSession, $result[0]);
            }
        
            public function testResolveWithSurvivalGameSession(): void
            {
                $gameSessionId = new Ulid();
                $request = new Request(attributes: ['id' => $gameSessionId]);
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn(GameSessionInterface::class);
        
                $this->dailyGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with($gameSessionId)
                    ->willReturn(null);
        
                $survivalGameSession = $this->createMock(SurvivalGameSession::class);
        
                $this->survivalGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with($gameSessionId)
                    ->willReturn($survivalGameSession);
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                $result = $gameSessionValueResolver->resolve($request, $argument);
        
                self::assertCount(1, $result);
                self::assertSame($survivalGameSession, $result[0]);
            }
        
            public function testResolveWithNoGameSessionFound(): void
            {
                $gameSessionId = new Ulid();
                $request = new Request(attributes: ['id' => $gameSessionId]);
                $argument = $this->createMock(ArgumentMetadata::class);
                $argument->expects(self::once())
                    ->method('getType')
                    ->willReturn(GameSessionInterface::class);
        
                $this->dailyGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with($gameSessionId)
                    ->willReturn(null);
        
                $this->survivalGameSessionRepository->expects(self::once())
                    ->method('find')
                    ->with($gameSessionId)
                    ->willReturn(null);
        
                $gameSessionValueResolver = new GameSessionValueResolver($this->dailyGameSessionRepository, $this->survivalGameSessionRepository);
                self::assertSame([], $gameSessionValueResolver->resolve($request, $argument));
            }
        }

        💰 Benefits of Argument Resolvers

        Implementing argument resolvers provides several advantages:
        1. Separation of Concerns: Your controllers focus on handling the request/response flow, while the resolvers handle the parameter conversion logic
        2. Code Reusability: The same resolver can be used across multiple controllers
        3. Maintainability: Adding new entity types doesn’t require changing your controller code
        4. Testability: You can test the parameter conversion logic in isolation
        5. Cleaner Controllers: Your controllers become more concise and focused

          Conclusion

          Custom argument resolvers are a powerful feature in Symfony that enables developers to create more elegant and maintainable applications. By abstracting the parameter conversion logic into dedicated classes, we can create polymorphic endpoints that handle different entity types seamlessly.
          In Respawwwn, this pattern allowed us to create a unified API endpoint for completing different types of game sessions, making our codebase more flexible and easier to maintain as we add new game modes in the future.
          Next time you find yourself writing complex parameter conversion logic in your controllers or duplicating endpoints for similar operations on different entity types, consider implementing a custom argument resolver.
          Your future self will thank you for the cleaner, more maintainable code!

          Sources

          Symfony Cast How Entity Controller Arguments Work.
          https://symfonycasts.com/screencast/deep-dive/entity-arguments
          Kevin Wenger (2020) Drupal 8 parameters upcasting for REST resources.
          https://antistatique.net/en/blog/drupal-8-parameters-upcasting-for-rest-resources
          Thomas Bertrand (2022) Symfony ParamConverter: the best friend you don’t know yet.
          https://medium.com/@ttbertrand/symfony-paramconverter-the-best-friend-you-dont-know-yet-c31ef2251683
          Symfony Documentation (2025) SensioFrameworkExtraBundle::ParamConverter.
          https://symfony.com/bundles/SensioFrameworkExtraBundle/current/annotations/converters.html

          Resources

          Respawwwn, https://respawwwn.com
          Symfony-based daily gaming quiz and example source for this article.
          Claude AI, https://claude.ai
          Assisted with writing and refining my English.
          DALL-E, https://openai.com
          Generated the very accurate article’s cover illustration.