Clean Controller Design in Symfony with Argument Resolvers

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:
- RequestPayloadValueResolver — Maps the request payload or the query string into the type-hinted object.
- RequestAttributeValueResolver — Attempts to find a request attribute that matches the name of the argument.
- DateTimeValueResolver — Attempts to find a request attribute that matches a DateTime format.
- RequestValueResolver — Injects the entire Request object when requested
- ServiceValueResolver — Injects a service.
- SessionValueResolver — Provides the session object.
- DefaultValueResolver — Handles parameters with default values.
- UidValueResolver — Convert any UID values from a route path parameter into UID objects
- VariadicValueResolver — Verifies if the request data is an array and will add all of them to the argument list.
- UserValueResolver — Resolves the current logged in user.
- SecurityTokenValueResolver — Resolves the current logged in token
- EntityValueResolver — Automatically fetches entities from the database based on the ID in the request (replaces ParamConverter)
- 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:
- It implements
ValueResolverInterface
and is tagged with#[AsTargetedValueResolver('game_session')]
to register it as a targeted value resolver in Symfony 6.3+ - The
resolve()
method checks if the argument type matches our interface - It then tries to find a matching game session in each repository
- 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:
- Separation of Concerns: Your controllers focus on handling the request/response flow, while the resolvers handle the parameter conversion logic
- Code Reusability: The same resolver can be used across multiple controllers
- Maintainability: Adding new entity types doesn’t require changing your controller code
- Testability: You can test the parameter conversion logic in isolation
- 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.