Comment créer ses propres ParamConverters avec Symfony ?
Le ParamConverter est un outil du SensioFrameworkExtraBundle. Il permet d’injecter automatiquement des objets dans les paramètres d’une action dont on a précisé le type. Nous aborderons dans un premier temps le fonctionnement de cet outil. Puis, nous verrons comment créer nos propres convertisseurs.
Comment fonctionne un convertisseur ?
/**
* @Route("/news/{id}")
*/
public function show(Article $news)
{
// Do something
}
Dans l’exemple ci-dessus, Symfony va être capable d’injecter dans le paramètre article l’objet d’identifiant `1` correspondant, sans que l’on ait quoi que ce soit d’autre à écrire. Vu d’ici, ça semble un peu magique. Abordons ce fonctionnement plus en détails.
Lorsque l’on appelle une action de contrôleur, un événement `kernel.controller` est déclenché. La classe `ParamConverterListener` va se charger d’écouter cet événement et de demander à Symfony d’exécuter tous les ParamConverter disponibles, un à un (il est possible de leur assigner un poids pour les prioriser). Cette action sera réalisée jusqu’à ce qu’il trouve un convertisseur capable de gérer les paramètres disponibles dans la signature de la fonction.
Il est également possible de préciser quel convertisseur utiliser pour tel ou tel argument grâce à l’annotation @ParamConverter.
Quand le bon convertisseur est trouvé, il va se passer plusieurs choses :
- Le convertisseur va d’abord essayer de trouver un objet correspondant avec les attributs de requête fournis, soit par un `find()` avec la clé primaire (`{id}` dans notre cas), soit par un `findBy()` si c’est une propriété de l’objet qui est fournie (ex : le slug).
- Si aucun objet n’est trouvé, le convertisseur retourne une 404.
- Si un objet est trouvé, il est ajouté aux attributs de la requête et automatiquement injecté dans le paramètre de l’action.
Par défaut, Symfony propose deux convertisseurs : un pour les objets DateTime et le convertisseur Doctrine.
C’est ce dernier qui est utilisé lorsque l’on va typer nos paramètres avec des objets doctrine dans la signature d’une action, comme dans l’exemple ci-dessus.
Je vous invite d’ailleurs à aller consulter plus en détail la documentation du `DoctrineParamConverter`. Celle-ci offre de nombreuses options intéressantes. Par exemple, récupérer un objet via une expression, faire du mapping de champ ou spécifier un `EntityManager` autre que celui par défaut.
Créer son propre ParamConverter
Comme nous venons de le voir, cet outil peut s’avérer très pratique pour alléger un peu le code de nos contrôleurs et nous simplifier la vie. Mais il peut arriver que l’on soit confrontés à des cas un peu plus complexes, que les convertisseurs de base ne peuvent pas résoudre.
Prenons l’exemple de deux classes `News` et `Advice`, qui étendent une classe Article.
class Article {
}
class News extends Article {
}
class Advice extends Article {
}
Comme elles ont sensiblement le même fonctionnement, on aimerait factoriser le code qui permet de les afficher sur notre site. L’action ressemblerait à ça :
/**
* @Route(
* "/conseils/{category}/{slug}-{article}",
* defaults={ "class"=Advice::class }
* )
* @Route(
* "/actualites/{category}/{slug}-{article}",
* defaults={ "class"=News::class }
* )
* @ParamConverter("article", converter="ArticleConverter")
*/
public function show(Request $request, ?Article $article)
{
// Do something
}
Une route pour afficher nos conseils (`Advice`), une pour afficher nos actualités (`News`), avec pour chacune un argument class qui contient le FQCN de la classe associée.
On pense bien à typer le paramètre avec la classe `Article`, et on ajoute l’annotation pour indiquer quel `ParamConverter` on souhaite utiliser.
La prochaine étape est la rédaction du convertisseur. Il va devoir étendre la classe `ParamConverterInterface`. Cette interface nous indique que notre convertisseur va devoir implémenter deux fonctions :
class ArticleConverter implements ParamConverterInterface
{
/** @var NewsRepository */
protected $newsRepository;
/** @var AdviceRepository */
protected $adviceRepository;
public function __construct
(
DoctrineNewsRepository $newsRepository,
DoctrineAdviceRepository $adviceRepository
)
{
$this->newsRepository = $newsRepository;
$this->adviceRepository = $adviceRepository;
}
public function apply(Request $request, ParamConverter $configuration)
{
}
public function supports(ParamConverter $configuration)
{
}
}
- `supports()` : cette fonction qui retourne un booléen indique si le convertisseur est capable de remplir le paramètre.
- `apply()` : c’est cette fonction qui va contenir la logique de notre `ParamConverter` et va ajouter le résultat aux attributs de la requête. Elle retourne true quand le paramètre a pû être rempli, false dans le cas contraire.
On notera également l’injection des deux repositories de nos classes `Advice` et `News`.
On va commencer par la fonction `supports()`, qui dans notre cas va devoir vérifier que l’argument que l’on récupère est bien de type `Article`. La variable `$configuration` contient les informations issues de l’annotation (typage de l’argument, options de l’annotation…). On va donc récupérer le type de notre argument via cette variable pour notre test.
public function supports(ParamConverter $configuration)
{
return $configuration->getName() === 'article';
}
Dans un second temps, on remplit la fonction `apply()` avec notre logique métier :
public function apply(Request $request, ParamConverter $configuration)
{
$id = $request->get('article');
$class = $request->get('class');
if (!$id) {
return false;
}
switch ($class) {
case Advice::class:
$request->attributes->set($configuration->getName(), $this->adviceRepository->find($id));
break;
case News::class:
$request->attributes->set($configuration->getName(), $this->newsRepository->find($id));
break;
default:
throw new \Exception(
sprintf(
'Expected an instance of %s or %s. Got: %s',
AdviceCategory::class, NewsCategory::class, $class
)
);
}
return true;
}
On récupère tout d’abord l’identifiant que l’on avait passé par la route, ainsi que le paramètre class que l’on avait passé par défaut dans la configuration de nos routes. Celui-ci va nous permettre de savoir quel type d’article on va devoir retourner à la vue.
Une fois le bon article récupéré, je l’ajoute à la variable attributes de la requête. C’est dans cette variable que Symfony va venir chercher les valeurs des arguments des actions de mon contrôleur.
La dernière étape consiste à déclarer notre convertisseur comme un service en lui ajoutant le tag `request.param_converter`.
ArticleConverter:
class: 'App\Infrastructure\ParamConverter\Article\ArticleConverter'
tags:
- { name: request.param_converter, converter: ArticleConverter }
Et c’est tout ! Désormais lorsque l’on va appeler notre action `show()`, le paramètre `$article` sera automatiquement remplis avec le bon objet `News` ou `Advice`, selon la route visitée.