Symfony 6.2 : Simplifiez vos contrôleurs avec ArgumentResolver

Avez-vous déjà eu l’impression de devoir faire des traitements répétitifs au début de vos contrôleurs Symfony ? Vous avez parfois besoin de convertir un paramètre en objet ? De créer un objet PHP à partir des variables de votre route ? Ou même, de créer une entité (Doctrine ou non) à partir d’un UUID passé au contrôleur ? Ou encore, de construire un formulaire à partir des données de la requête ?

Sans le savoir, vous utilisez certains convertisseurs intégrés à Symfony. C’est ce qui se passe lorsque vous récupérez l’objet SessionInterface par exemple. Vous utilisez également des convertisseurs lorsque vous récupérez une entité Doctrine en paramètre de votre contrôleur, l’identifiant de la route est automatiquement converti en entité.

Imaginez maintenant que vous puissiez mettre en place ce type de mécanisme sur votre propre architecture (sans Doctrine ni FrameworkExtraBundle).

C’est ce que permet l’interface Symfony\Component\HttpKernel\Controller\ValueResolverInterface qui vient remplacer Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter (déprécié avec Symfony 6.2)

Exemple : Créer un objet à partir de plusieurs paramètres 👀

Imaginons un objet Search qui serait créé à partir de 3 paramètres (Terms, Location et Date). Vous pourriez le manipuler de cette manière dans votre contrôleur :

<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;

class SearchController
{

    #[Route(path: '/search', name: 'search')]
    public function search(Request $request): Response
    {
        // Converti la date en DateTimeImmutable
        $date = DateTimeImmutable::createFromFormat('Y-m-d', $request->request->get('date'));

        // Créé un objet Search en fonction des paramètres obligatoires
        $search = new Search(
            $request->request->get('terms'),
            $request->request->get('location'),
            $date
        );

        // ... Utilise $search pour traiter et retourner la réponse
    }
}

Voici ce que cela donnerait après la mise en place d’un convertisseur automatique de paramètre (ArgumentResolver):

<?php

namespace App\Controller;

class SearchController
{

    #[Route(path: '/search', name: 'search')]
    public function search(Search $search): Response
    {
        // ... Utilise $search pour traiter et retourner la réponse
    }
}

Comment créer un ArgumentResolver dans Symfony ? 🤩

Vous devez commencer par créer un nouveau service Symfony qui implémente l’interface Symfony\Component\HttpKernel\Controller\ValueResolverInterface.

<?php
//src/ArgumentResolver/SearchArgumentResolver.php

namespace App\ArgumentResolver;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

class SearchArgumentResolver implements ValueResolverInterface
{
    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        return [];
    }
}

La fonction resolve() va être appelée systématiquement pour tous les paramètres de tous les contrôleurs de votre application. Vous devez donc systématiquement réaliser deux actions :

  • Vérifier que le paramètre en question est supporté par ce résolveur
  • Si oui, appliquer la conversion et retourner la valeur sous forme de tableau
<?php
//src/ArgumentResolver/SearchArgumentResolver.php

namespace App\ArgumentResolver;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

class SearchArgumentResolver implements ValueResolverInterface
{
    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        // Si le paramètre n'est pas supporté
        if (!$this->supports($argument)) {
            // Ne modifie pas le paramètre
            return [];
        }

        // Applique la conversion
        return $this->apply($request);
    }

    private function supports(ArgumentMetadata $argument): bool
    {
        // Vérifie le type attendu par le contrôleur
        return $argument->getType() === Search::class;
    }

    private function apply(Request $request): array
    {
        // Converti la date en DateTimeImmutable
        $date = DateTimeImmutable::createFromFormat('Y-m-d', $request->request->get('date'));

        // Créé un objet Search en fonction des paramètres obligatoires
        $search = new Search(
            $request->request->get('terms'),
            $request->request->get('location'),
            $date
        );

        return [$search];
    }
}

Pour que votre ArgumentResolver puisse s’appliquer correctement, il va falloir le charger dans un certain ordre afin qu’il ne soit pas écrasé par d’autre ArgumentResolver déjà présent dans Symfony. Pour cela, il est nécessaire d’ajouter une priorité de chargement supérieure à 100.

#[AutoconfigureTag('controller.argument_value_resolver', ['priority' => 150])]
class SearchArgumentResolver implements ValueResolverInterface 
{
    // ... 
}

N’abusez pas des ArgumentResolver 😏

Bien que cette fonctionnalité soit séduisante, il faut veiller à ne pas en abuser. Vous allez simplifier vos contrôleurs mais, en contre-partie, vous ajoutez de la magie à votre projet.

Si une autre personne reprend le code, il n’est pas sûr qu’elle comprenne aussitôt que l’objet Search est construit à travers un ArgumentResolver.

N’utilisez pas systématiquement cette technique, réservez là pour des objets qui sont présent dans de nombreux contrôleurs. Si vous décidez d’appliquer cette technique comme une règle d’architecture de votre projet, pensez à le documenter. 👍

Ressources récentes