Clean Controller Design in Symfony with Argument Resolvers

Kevin Wenger

--

Look at that weavy keyboard 🏄 🌊 by DALL-E 🤖

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. RequestPayloadValueResolverMaps 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. ServiceValueResolverInjects a service.
  6. SessionValueResolver — Provides the session object.
  7. DefaultValueResolver — Handles parameters with default values.
  8. UidValueResolverConvert 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. SecurityTokenValueResolverResolves 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

The official symfony documentation.

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

Drupal Documentation (2019) How upcasting parameters works.
https://www.drupal.org/docs/8/api/routing-system/parameters-in-routes/how-upcasting-parameters-works

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 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.

This article has been first write for the Blog of Antistatique — Web Agency in Lausanne, Switzerland.
A place where I work as Senior Backend Developer. Feel free to read it here or check it out there: https://antistatique.net/en/blog

All images copyright of their respective owners.
Big thanks to @Antistatique for the review.

--

--

No responses yet

Write a response