Accéder au repository depuis votre entity dans Symfony 2

… C’est impossible. Et oui, moi aussi ça m’a fait un choc.

Pourtant, mon problème initial me semblait particulièrement simple et logique : j’ai une entité User qui possède plusieurs entités Pictures, et je voulais créer une propriété personnalisée User->getAvatar() récupérant une seule entité Picture qui aurait été définie comme « isMain ».

Seulement non. on ne peut pas faire :

<?php // Entity\User
public function getAvatar() {
	return $this->getRepository('UserBundle:Picture')->getUserAvatar($this->id)->getQuery()->getSingleResult();
}

et donc on ne peut pas non plus faire dans twig :

{{ user.avatar }}

… Alors comment allons nous faire ?

Et bien j’aimerai vous dire que c’est simple. Mais ça ne l’ai pas.

Le tutoriel

Il faut créer un service UserManager et une extension twig, en plus bien sûr de notre fonction dans le repository.

Commençons par notre fonction dans le répository PictureRepository :

<?php // Entity\PictureRepository :
public function getUserAvatar($_id) {
	$query = $this->createQueryBuilder('p')
		->where('p.user = :userId')
		->setParameter(':userId', $_id)
		->orderBy('p.isMain', 'DESC') // j'utilise OrderBy dans le cas ou il y aurais effectivement des pictures associée, mais aucune définie comme main
	;

	return $query;
}

Voilà pour la fonction qui va nous permettre de récupérer l’avatar. Remarquez que l’on renvoie seulement la requête, et non son résultat. Cela permet de pouvoir réutiliser la même requête ailleurs si on a besoin d’y ajouter de nouvelles conditions par exemple.

Nous allons maintenant créer le service UserManager qui va se charger d’appeler les fonctions des répositories :

<?php // Entity\UserManager

namespace MyBundle\Entity;

use Doctrine\ORM\EntityManager;
use MyBundle\Entity\User;

class UserManager {

	protected $em;

	public function __construct(EntityManager $em) {
		$this->em = $em;
	}

	/**
	* return Picture is main for user
	* @param MyBundle\Entity\User $user
	* @return MyBundle\Entity\Picture
	*/
	public function getAvatar(User $user) {
		$avatar = $this->em->getRepository('MyBundle:Picture')->getUserAvatar($user->getId())->setMaxResults(1)->getQuery()->getOneOrNullResult();
		return !$avatar ? new Picture() : $avatar;
	}

}

Dans notre service, nous avons donc bien la fonction getAvatar() qui va aller chercher notre méthode getUserAvatar créée plus tôt. Remarquez également que notre constructeur prend en paramètre le manager d’entité de notre application.

Pour cela, il faut informer symfony que notre nouvelle classe UserManager est bien un service :

# MyBundle\Resources\config\services.yml
services
  my_user.user_manager:
    class: MyBundle\Entity\UserManager
    arguments: ["@doctrine.orm.entity_manager"]

Notre classe UserManager sera maintenant accessible dans les autres service et dans vos contrôleurs.

Il ne nous reste plus qu’à créer notre extension twig pour pouvoir accéder à notre objet Avatar dans nos templates :

<?php // MyBundle\Twig

namespace MyBundle\Twig;

use MyBundle\Entity\UserManager;

class UserExtension extends \Twig_Extension {

	protected $manager;

	public function __construct(UserManager $manager) {
		$this->manager = $manager;
	}

	public function getFunctions() {
		return array(
			'avatar' => new \Twig_Function_Method($this, 'avatarFunction'),
		);
	}

	/**
	* Function rendering the avatar Picture object of user
	* @param User $user
	* @return Picture
	*/
	public function avatarFunction($user) {
		return $this->manager->getAvatar($user);
	}

	public function getName() {
		return 'user_extension';
	}

}

Et finallement, on en fait également un service pour que Twig soit informé de cette nouvelle extention :

my_user.user_extension:
  class: MyBundle\Twig\UserExtension
  arguments: ["@my_user.user_manager"]
  tags:
    - { name: twig.extension }

Enfin, notre objet « Avatar » est complètement accessible, y compris dans Twig, et nous pouvons accéder à ses propriétés, comme le chemin vers le fichier image de notre Picture :

<img alt="" src="{{ avatar(user).webpath }}" />

Mes conclusions

Tout d’abord, force est de constater que le code est considérablement alourdi. On choisis d’utiliser un framework comme Symfony pour gagner du temps sur le développement, mais quand on voit ça, on ne peut que ce demander si on y arrive vraiment… On vient d’écrire plus de 30 lignes de code pour une fonction qui en prend normalement 3, et ce dans 4 fichiers différents…

De plus, ça rend également plus complexe la maintenabilité du code… Je n’ose imaginer ce qu’il se passerait si jamais on devait avoir des mises à jours à ce niveau.

Enfin je reviens sur le cœur du problème. Symfony nous impose ce système en prétendant que les entités ne doivent être utilisées uniquement pour représenter strictement le schéma de la base de donnée. Hors sur ce point, on voit qu’ils se contredisent eux-même dans leurs tutoriels en y créant de méthodes personnalisées : http://symfony.com/doc/current/cookbook/doctrine/file_uploads.html.

Quelle est donc la différence entre créer une méthode « getWebPath » et une méthode « getAvatar » qui fait appel à un repository ?

Vous me répondrez peut-être qu’un repository fait appel à la base de donnée via une requête.

Il me semble que c’est pourtant la fonction première d’une entité. Toutes ses propriétés résultent d’un appel à la BDD.

C’est le genre de choses qui étaient tellement simple avec Symfony 1 et qui font de Symfony 2 un véritable enfer.

Note complémentaire

Si ce code est fonctionnel, notez qu’il reste encore un hic : à chaque fois que vous appellerez votre nouvelle fonction avatar(), Doctrine va à nouveau exécuter la même requête, ce qui est inutile. Pour éviter cela, vous pouvez soit utiliser le cache de résultat fourni par Doctrine, soit créer quand même une propriété publique avatar à votre objet user et la remplir lors du premier appel de votre service UserManager->getAvatar().


Mes plus chaleureux remerciement à amenophis du chan #symfony sur IRC qui m’a expliqué le truc 😉