Licence CC BY-NC-SA

Utiliser des ParamConverters pour convertir les paramètres de requêtes

L'objectif des ParamConverters, ou « convertisseurs de paramètres », est de vous faire gagner du temps et des lignes de code. Sympa, non ? :)

Il s'agit de transformer automatiquement un paramètre de route, comme {id} par exemple, en un objet, une entité $article par exemple. Vous ne pourrez plus vous en passer ! Et bien entendu, il est possible de créer vos propres convertisseurs, qui n'ont de limite que votre imagination. ;)

Théorie : pourquoi un ParamConverter ?

Récupérer des entités Doctrine avant même le contrôleur

Sur la page d'affichage d'un article de blog, par exemple, n'êtes-vous pas fatigués de toujours devoir vérifier l'existence de l'article demandé, et de l'instancier vous-mêmes ? N'avez-vous pas l'impression d'écrire toujours et encore les mêmes lignes ?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
// src/Sdz/BlogBundle/Controller/ArticleController.php

// …

public function voirAction($id)
{
  $em      = $this->getDoctrine()->getManager();
  $article = $em->find('Sdz\BlogBundle\Entity\Article', $id);

  if (null !== $article) {
    throw $this->createNotFoundException('L\'article demandé [id='.$id.'] n\'existe pas.');
  }

  // Ici seulement votre vrai code…

  return $this->render('SdzBlogBundle:Blog:voir.html.twig', array('article' => $article));
}

Pour enfin vous concentrer sur votre code métier, Symfony2 a évidemment tout prévu !

Les ParamConverters

Vous pouvez créer ou utiliser des ParamConverters qui vont agir juste avant le contrôleur. Comme son nom l'indique, un ParamConverter convertit les paramètres de votre route au format que vous préférez. En effet, depuis la route, vous ne pouvez pas tellement agir sur vos paramètres. Tout au plus, vous pouvez leur imposer des contraintes via des expressions régulières. Les ParamConverters pallient cette limitation en agissant après le routeur pour venir transformer à souhait ces paramètres.

Le résultat des ParamConverters est stocké dans les attributs de requête, c'est-à-dire qu'on peut les injecter dans les arguments de l'action du contrôleur.

Un ParamConverter utile : DoctrineParamConverter

Vous l'aurez deviné, ce ParamConverter va nous convertir nos paramètres directement en entités Doctrine ! L'idée est la suivante : dans le contrôleur, au lieu de récupérer le paramètre de route {id} sous forme de variable $id, on va récupérer directement une entité Article sous la forme d'une variable $article, qui correspond à l'article portant l'id $id.

Et un bonus en prime : on veut également que, s'il n'existe pas d'article portant l'id $id dans la base de données, alors une exception 404 soit levée. Après tout, c'est comme si l'on mettait dans la route : requirements: Article exists !

Un peu de théorie sur les ParamConverters

Comment fonctionne un ParamConverter ?

Un ParamConverter est en réalité un simple listener, qui écoute l'évènement kernel.controller.

On l'a vu dans le chapitre sur les évènements, cet évènement est déclenché lorsque le noyau de Symfony2 sait quel contrôleur exécuter (après le routeur, donc), mais avant d'exécuter effectivement le contrôleur. Ainsi, lors de cet évènement, le ParamConverter va lire la signature de la méthode du contrôleur pour déterminer le type de variable que vous voulez. Cela lui permet de créer un attribut de requête du même type, à partir du paramètre de la route, que vous récupérez ensuite dans votre contrôleur.

Pour déterminer le type de variable que vous voulez, le ParamConverter a deux solutions. La première consiste à regarder la signature de la méthode du contrôleur, c'est-à-dire le typage que vous définissez pour les arguments :

1
2
<?php
public function testAction(Article $article)

Ici, le typage est Article, devant le nom de la variable. Le ParamConverter sait alors qu'il doit créer une entité Article.

La deuxième solution consiste à utiliser une annotation @ParamConverter, ce qui nous permet de définir nous-mêmes les informations dont il a besoin.

Au final, depuis votre contrôleur, vous avez en plus du paramètre original de la route un nouvel argument créé par votre ParamConverter qui s'est exécuté avant votre contrôleur. Et bien entendu, il sera possible de créer vos propres ParamConverters ! ;)

Pratique : utilisation des ParamConverters existants

Utiliser le ParamConverter Doctrine

Ce ParamConverter fait partie du bundle Sensio\FrameworkBundle. C'est un bundle activé par défaut avec la distribution standard de Symfony2, que vous avez si vous suivez ce cours depuis le début.

Vous pouvez donc vous servir du DoctrineParamConverter. Il existe plusieurs façon de l'utiliser, avec ou sans expliciter l'annotation. Voyons ensemble les différentes méthodes.

1. S'appuyer sur l'id et le typage de l'argument

C'est la méthode la plus simple, et peut-être la plus utilisée. Imaginons que vous ayez cette route :

1
2
3
4
5
6
7
# src/Sdz/BlogBundle/Resources/config/routing.yml

sdzblog_voir:
    path:      /blog/article/{id}
    defaults:  { _controller: SdzBlogBundle:Blog:voir }
    requirements:
        id: \d+

Une route somme toute classique, dans laquelle figure un paramètre {id}. On a mis une contrainte pour que cet id soit un nombre, très bien. Le seul point important est que le paramètre s'appelle « id », ce qui est aussi le nom d'un attribut de l'entité Article.

Maintenant, la seule chose à changer pour utiliser le DoctrineParamConverter est côté contrôleur, où il faut typer un argument de la méthode, comme ceci :

1
2
3
4
5
6
7
8
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php
use Sdz\BlogBundle\Entity\Article;

public function voirAction($id, Article $article)
{
  // Ici, $article est une instance de l'entité Article, portant l'id $id
}

Faites le test ! Vous verrez que $article est une entité pleinement opérationnelle. Vous pouvez l'afficher, créer un formulaire avec, etc. Bref, vous venez d'économiser le $em->find() nécessaire pour récupérer manuellement l'entité !

De plus, si vous mettez dans l'URL un id qui n'existe pas, alors le DoctrineParamConverter vous lèvera une exception, résultant en une page d'erreur 404 comme dans la figure suivante.

J'ai tenté d'afficher un article qui n'existe pas, voici la page d'erreur 404

Ici j'ai laissé l'argument $id dans la définition de la méthode, mais vous pouvez tout à fait l'enlever. Vu qu'on a l'article dans la variable $article, $id n'est plus utile, on peut (et on doit) utiliser $article->getId(). C'était juste pour vous montrer que le ParamConverter crée un attribut de requête, sans toucher à ceux existants.

Quand je parle d'attributs de requête, ce sont les attributs que vous pouvez récupérer de cette manière : $request->attributes->get('article'). Ce sont ces attributs que vous pouvez injecter dans le contrôleur en tant qu'arguments de la méthode (dans l'exemple précédent, il s'agit de $id et $article). Mais ce n'est en rien obligatoire, si vous ne les injectez pas ils seront tout de même attributs de la requête.

Avec cette méthode, la seule information que le ParamConverter utilise est le typage d'un argument de la méthode, et non le nom de l'argument. Par exemple, vous pourriez tout à fait avoir ceci :

1
2
<?php
public function voirAction(Article $bidule)

Cela ne change en rien le comportement, et la variable $bidule contiendra une instance de l'entité Article.

Une dernière note sur cette méthode. Ici cela a fonctionné car le paramètre de la route s'appelle « id », et que l'entité Article a un attribut id. En fait, cela fonctionne avec tous les attributs de l'entité Article ! Appelez votre paramètre de route titre, et accédez à une URL de type /blog/article/titre-existant, cela fonctionne exactement de la même manière ! Cependant, pour l'utilisation d'autres attributs que l'id, je vous conseille d'utiliser les méthodes suivantes.

2. Utiliser l'annotation pour faire correspondre la route et l'entité

Il s'agit maintenant d'utiliser explicitement l'annotation de DoctrineParamConverter, afin de personnaliser au mieux le comportement. Considérons maintenant que vous avez la route suivante :

1
2
3
4
5
# src/Sdz/BlogBundle/Resources/config/routing.yml

sdzblog_voir:
    path:      /blog/article/{article_id}
    defaults:  { _controller: SdzBlogBundle:Blog:voir }

La seule différence est que le paramètre de la route s'appelle maintenant « article_id ». On aurait pu tout aussi bien l'appeler « bidule », l'important est que ce soit un nom qui n'est pas également un attribut de l'entité Article. Le ParamConverter ne peut alors pas faire la correspondance automatiquement, il faut donc le lui dire.

Cela ne nous fait pas peur ! Voici l'annotation à utiliser :

1
2
3
4
5
6
7
8
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;

/**
 * @ParamConverter("article", options={"mapping": {"article_id": "id"}})
 */
public function voirAction(Article $article)

Il s'agit maintenant d'être un peu plus rigoureux. Dans l'annotation @ParamConverter, voici ce qu'il faut renseigner :

  • Le premier argument de l'annotation correspond au nom de l'argument de la méthode que l'on veut injecter. Le article de l'annotation correspond donc au $article de la méthode.
  • Le deuxième argument correspond aux options à passer au ParamConverter. Ici nous avons passé une seule option mapping. Cette option fait la correspondance « paramètre de route » => « attribut de l'entité ». Dans notre exemple, c'est ce qui permet de dire au ParamConverter : « le paramètre de route article_id correspond à l'attribut id de l'Article ».

Le ParamConverter connaît le type d'entité à récupérer (Article, Categorie, etc.) en lisant, comme précédemment, le typage de l'argument.

Bien entendu, il est également possible de récupérer une entité grâce à plusieurs attributs. Prenons notre entité ArticleCompetence par exemple, qui est identifiée par deux attributs : article et competence. Il suffit pour cela de passer les deux attributs dans l'option mapping de l'annotation, comme suit :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;

// La route serait par exemple : /blog/{article_id}/{competence_id}

/**
 * @ParamConverter("articleCompetence", options={"mapping": {"article_id": "article", "competence_id": "competence"}})
 */
public function voirAction(ArticleCompetence $articleCompetence)

3. Utiliser les annotations sur plusieurs arguments

Grâce à l'annotation, il est alors possible d'appliquer plusieurs ParamConverters à plusieurs arguments. Prenez la route suivante :

1
2
3
4
5
# src/Sdz/BlogBundle/Resources/config/routing.yml

sdzblog_voir:
    path:      /blog/article/{article_id}/commentaires/{commentaire_id}
    defaults:  { _controller: SdzBlogBundle:Blog:voir }

L'idée ici est d'avoir deux paramètres dans la route, qui vont nous permettre de récupérer deux entités grâce au ParamConverter.

Vous l'aurez compris, avec deux paramètres à convertir il vaut mieux tout expliciter grâce aux annotations, plutôt que de reposer sur une devinette. :p La mise en application est très simple, il suffit de définir deux annotations, chacune très simple comme on l'a déjà vu. Voici comment le faire :

1
2
3
4
5
6
<?php
/**
 * @ParamConverter("article",     options={"mapping": {"article_id": "id"})
 * @ParamConverter("commentaire", options={"mapping": {"commentaire_id": "id"})
 */
public function voirAction(Article $article, Commentaire $commentaire)

Utiliser le ParamConverter Datetime

Ce ParamConverter est plus simple : il se contente de convertir une date d'un format défini en un objet de type Datetime. Très pratique !

Partons donc de cette route par exemple :

1
2
3
4
5
# src/Sdz/BlogBundle/Resources/config/routing.yml

sdzblog_liste:
    path:      /blog/{date}
    defaults:  { _controller: SdzBlogBundle:Blog:voirListe }

Et voici comment utiliser le convertisseur sur la méthode du contrôleur :

1
2
3
4
5
6
7
8
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;

/**
 * @ParamConverter("date", options={"format": "Y-m-d"})
 */
public function voirListeAction(\Datetime $date)

Ainsi, au lieu de simplement recevoir l'argument $date qui vaut « 2012-09-19 » par exemple, vous récupérez directement un objet Datetime à cette date, vraiment sympa.

Attention, ce ParamConverter fonctionne différemment de celui de Doctrine. En l'occurrence, il ne crée pas un nouvel attribut de requête, mais remplace l'existant. La conséquence est que le nom de l'argument (ici, $date) doit correspondre au nom du paramètre dans la route (ici, {date}).

Aller plus loin : créer ses propres ParamConverters

Comment sont exécutés les ParamConverters ?

Avant de pouvoir créer notre ParamConverter, étudions comment ils sont réellement exécutés.

À l'origine de tout, il y a un listener, il s'agit de Sensio\Bundle\FrameworkExtraBundle\EventListener\ParamConverterListener. Ce listener écoute l'évènement kernel.controller, ce qui lui permet de connaître le contrôleur qui va être exécuté. L'idée est qu'il parcourt les différents ParamConverters pour exécuter celui qui convient le premier. On peut synthétiser son comportement par le code suivant :

1
2
3
4
5
6
7
8
9
<?php

foreach ($converters as $converter) {
  if ($converter->supports($configuration)) {
    if ($converter->apply($request, $configuration)) {
      return;
    }
  }
}

Vous n'avez pas à écrire ce code, je vous le donne uniquement pour que vous compreniez le mécanisme interne !

Dans ce code :

  • La variable $converters contient la liste de tous les ParamConverters, nous voyons plus loin comment elle est construite ;
  • La méthode $converter->supports() demande au ParamConverter si le paramètre actuel l'intéresse ;
  • La variable $configuration contient les informations de l'annotation : le typage de l'argument, les options de l'annotation, etc. ;
  • La méthode $converter->apply() permet d'exécuter à proprement parler le ParamConverter.

L'ordre des convertisseurs est donc très important, car si le premier retourne true lors de l'exécution de sa méthode apply(), alors les éventuels autres ne seront pas exécutés.

Comment Symfony2 trouve tous les convertisseurs ?

Pour connaître tous les convertisseurs, Symfony2 utilise un mécanisme que nous avons déjà utilisé : les tags des services. Vous l'aurez compris, un convertisseur est avant tout un service, sur lequel on a appliqué un tag.

Commençons donc par créer la définition d'un service, que nous allons implémenter en tant que ParamConverter :

1
2
3
4
5
6
7
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.paramconverter_test
        class: Sdz\BlogBundle\ParamConverter\TestParamConverter
        tags:
            - { name: request.param_converter, priority: 20 }

On a ajouté un tag request.param_converter sur notre service, ce qui permet de l'enregistrer en tant que tel. J'ai mis ici une priorité de 20, histoire de passer avant le ParamConverter de Doctrine. À vous de voir si vous voulez passer avant ou après, suivant les cas.

Créer un convertisseur

Créons maintenant la classe du ParamConverter. Un convertisseur doit implémenter l'interface ParamConverterInterface. Commençons par créer la classe d'un convertisseur TestParamConverter sur ce squelette, que je place dans le répertoire ParamConverter du bundle :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
// src/Sdz/BlogBundle/ParamConverter/TestParamConverter.php

namespace Sdz\BlogBundle\ParamConverter;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationInterface;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;

class TestParamConverter implements ParamConverterInterface
{
  function supports(ConfigurationInterface $configuration)
  {
  }

  function apply(Request $request, ConfigurationInterface $configuration)
  {
  }
}

L'interface ne définit que deux méthodes : supports() et apply().

La méthode supports()

La méthode supports() doit retourner true lorsque le convertisseur souhaite convertir le paramètre en question, false sinon. Les informations sur le paramètre courant sont stockées dans l'argument $configuration, et contiennent :

  • $configuration->getClass() : le typage de l'argument dans la méthode du contrôleur ;
  • $configuration->getName() : le nom de l'argument dans la méthode du contrôleur ;
  • $configuration->getOptions() : les options de l'annotation, si elles sont explicitées (vide bien sûr lorsqu'il n'y a pas l'annotation).

Vous devez, avec ces trois éléments, décider si oui ou non le convertisseur compte convertir le paramètre.

La méthode apply()

La méthode apply() doit effectivement créer un attribut de requête, qui sera injecté dans l'argument de la méthode du contrôleur.

Ce travail peut être effectué grâce à ses deux arguments :

  • La configuration, qui contient les informations sur l'argument de la méthode du contrôleur, que nous avons vu juste au-dessus ;
  • La requête, qui contient tout ce que vous savez, et notamment les paramètres de la route courante via $request->attributs->get('paramètre_de_route').

L'exemple de notre TestParamConverter

Histoire de bien comprendre ce que chaque méthode et chaque variable doit faire, je vous propose un petit exemple. Imaginons que notre site internet est disponible via différents noms de domaine. Dans une certaine action de contrôleur, on veut récupérer une entité Site, dont l'attribut hostname correspond au nom de domaine courant.

Du coup, notre TestParamConverter ne se sert même pas d'un des paramètres de la route courante. Il ne va se servir que du nom de domaine contenu directement dans la requête. C'est pourquoi la route n'a rien de particulier, elle peut tout à fait ne contenir aucun paramètre.

Le service

Tout d'abord, on a besoin de l'EntityManager dans notre service, afin de pouvoir récupérer l'entité Site qui nous intéresse. On va également passer en paramètre le nom de la classe de l'entité Site en question. Normalement vous savez le faire, voici comment je l'ai réalisé à partir de la définition du service qu'on a écrite plus haut :

1
2
3
4
5
6
7
8
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.paramconverter_test
        class: Sdz\BlogBundle\ParamConverter\TestParamConverter
        arguments: ['Sdz\BlogBundle\Entity\Site', @doctrine.orm.entity_manager]
        tags:
            - { name: request.param_converter, priority: 20 }

La classe

Ensuite, il faut modifier le squelette du convertisseur qu'on a réalisé plus haut. Voici mon implémentation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
// src/Sdz/BlogBundle/ParamConverter/TestParamConverter.php

namespace Sdz\BlogBundle\ParamConverter;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\ConfigurationInterface;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManager;

class TestParamConverter implements ParamConverterInterface
{
  protected $class;
  protected $repository;

  public function __construct($class, EntityManager $em)
  {
    $this->class      = $class;
    $this->repository = $em->getRepository($class);
  }

  function supports(ConfigurationInterface $configuration)
  {
    // $conf->getClass() contient la classe de l'argument dans la méthode du contrôleur
    // On teste donc si cette classe correspond à notre classe Site, contenue dans $this->class
    return $configuration->getClass() == $this->class;
  }

  function apply(Request $request, ConfigurationInterface $configuration)
  {
    // On récupère l'entité Site correspondante
    $site = $this->repository->findOneByHostname($request->getHost());

    // On définit ensuite un attribut de requête du nom de $conf->getName()
    // et contenant notre entité Site
    $request->attributes->set($configuration->getName(), $site);

    // On retourne true pour qu'aucun autre ParamConverter ne soit utilisé sur cet argument
    // Je pense notamment au ParamConverter de Doctrine qui risque de vouloir s'appliquer !
    return true;
  }
}

Le contrôleur

Pour utiliser votre convertisseur flambant neuf, il ne faut pas grand-chose. Comme je vous l'ai mentionné précédemment, on n'utilise pas ici de paramètre venant de la route. L'utilisation est donc très simple : il faut juste ajouter un argument, typé en Site, comme ceci :

1
2
3
4
5
6
7
8
9
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

use Sdz\BlogBundle\Entity\Site;

public function indexAction($page, Site $site)
{
  // Ici, $site est une instance de Site avec comme hostname « localhost » (si vous testez en local !)
}

Pas besoin d'expliciter l'annotation ici, car nous n'avons aucune option particulière à passer au ParamConverter. Mais si vous en aviez, c'est le seul moyen de le faire donc gardez-le en tête. ;)

Maintenant, à chaque fois que vous avez besoin d'accéder à l'entité Site correspondant au nom de domaine sur lequel vous êtes, il vous suffira de rajouter un argument Site $site dans la méthode de votre contrôleur. C'est tout ! Le ParamController s'occupe du reste.

Bien sûr, mon exemple est incomplet. Il faudrait une gestion des erreurs, notamment lorsqu'il n'existe pas d'entité Site avec pour hostname le domaine sur lequel vous êtes. Il suffirait pour cela de déclencher une exception NotFoundHttpException. Je vous invite à vous inspirer de ceux existants : DoctrineParamConverter et DatetimeParamConverter


  • Un ParamConverter vous permet de créer un attribut de requête, que vous récupérez ensuite en argument de vos méthodes de contrôleur ;
  • Il existe deux ParamConverters par défaut avec Symfony2 : Doctrine et Datetime ;
  • Il est facile de créer ses propres convertisseurs pour accélérer votre développement.