Introduction
Qu'allons-nous faire ?
Sans doute qu'aujourd'hui, lors de l'installation d'un nouveau projet Symfony avec Doctrine, vous gardez le design de base de ces classes, avec une classe qui étend du repository de base de Doctrine, l'EntityRepository.
Quel est le problème me direz-vous ? Eh bien tout d'abord, mettons que vous n'ayez besoin que de la méthode find de votre repository. Ici, vous hériterez bien de cette méthode, mais également de toutes les autres venant du repository de base de Doctrine. Ainsi, n'importe quel développeur pourrait utiliser le getEntityManager qui permet ensuite d'effectuer toutes les opérations de mutation (insertion, mise à jour, suppression) d'un objet dans votre base de données, et ce même si vous ne souhaitez pas que ce soit possible.
De plus, l'EntityManager est un concept étroitement lié à Doctrine dans notre cas.
Ainsi, on dira que le détail de notre implémentation (ici Doctrine et toutes ses méthodes & ses concepts) va fuir dans notre code métier, et ce n'est pas ce que l'on veut.
Pour régler cela, nous allons utiliser l'injection de dépendance.
De plus, nous aurons besoin d'être aidé par un typage plus strict pour faciliter et améliorer notre expérience de développement. Pour cela, nous utiliserons le typage générique. Comme vous le savez sûrement, ce n'est pas possible nativement en PHP de faire ce genre de typage, mais grâce à l'outil d'analyse statique PHPStan, nous contournerons ce problème. En cela, ce Codelabs est une mise en application de cet article sur le typage générique en PHP sorti sur le blog d'Eleven Labs. Allez y jeter un oeil !
Enfin, le code source de ce Codelabs est disponible sur mon GitHub :
Pré-requis
Pour les besoins de ce tutoriel il vous faudra :
- Avoir PHP d'installé sur votre machine (ou via Docker si vous vous sentez plus à l'aise)
- Avoir des bases en Symfony & Doctrine
- Avoir lu notre article sur le typage générique en PHP (idéalement)
Le tout est développé avec PHP 8.2
Initialisation du projet
Pour commencer, initialisez un nouveau projet Symfony selon votre OS.
Pour moi (Ubuntu), ce sera comme cela :
$ symfony new codelabs-symfo-repo
À présent, installons les différentes librairies requises, en commençant par Doctrine.
$ composer require symfony/orm-pack
On installe le maker de Symfony pour aller plus vite :
$ composer require --dev symfony/maker-bundle
Enfin, installons PHPStan dès à présent pour plus tard.
$ composer require --dev phpstan/phpstan
Super, nous sommes à présent prêts à créer notre entité qui nous accompagnera tout le long de ce codelabs.
Faisons simple, et créons une entité Post avec un title et un content avec la commande :
$ bin/console make:entity
Super ! Comme vous le savez, cette commande a créé une nouvelle entité ainsi que son repository, respectivement dans les dossiers src/Entity et src/Repository.
On remarque que notre repository étend par défaut le ServiceEntityRepository de Doctrine, qui contient toutes les méthodes :
/** * @extends ServiceEntityRepository<Post> * * @method Post|null find($id, $lockMode = null, $lockVersion = null) * @method Post|null findOneBy(array $criteria, array $orderBy = null) * @method Post[] findAll() * @method Post[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class PostRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Post::class); } // ...
Note
Notez au passage le tag ServiceEntityRepository<Post> qui est un avant-goût de ce que nous ferons plus tard avec le typage générique.
Comme on peut l'observer, notre repository est extrêmement couplé à Doctrine. Et de nombreuses méthodes que nous n'utiliserons jamais viennent polluer cette classe. Nous allons donc nettoyer et alléger notre repository dans la prochaine partie.
Refactoring du repository
Pour que la suite soit la plus digeste et facile possible, nous travaillerons dans l'arborescence de fichier créée par Symfony, directement dans le dossier Repository.
Dans le monde réel, on pourrait clairement tirer parti du DDD (Domain Driven Design) pour sublimer notre refactoring, mais ce n'est pas l'objet de ce codelab, nous resterons donc concentrés sur le repository en tant que tel.
La première chose à faire pour se découpler de Doctrine, c'est de créer notre propre interface, contenant la liste exhaustive des méthodes que notre repository devra implémenter. Ici, admettons que nous aurons uniquement besoin des méthodes store() pour persister des objets, find() pour en récupérer, et une plus particulière findPostsAboutPhp().
Créons donc cette interface :
<?php declare(strict_types=1); namespace App\Repository; use App\Entity\Post; interface PostRepositoryInterface { public function store(Post $post): void; public function find(int $id): ?Post; public function findPostsAboutPhp(): array; }
À présent c'est ce contrat d'interface qui fait foi pour notre PostRepository.
De plus cette interface est totalement agnostique de tout ORM (ici, Doctrine), car le choix de votre ORM est un détail d'implémentation dont votre code métier ne doit pas avoir connaissance.
Dorénavant, vous devrez donc toujours utiliser la PostRepositoryInterface quand vous voudrez injecter votre repository quelque part.
Revenons maintenant à notre implémentation de repository Doctrine. Commençons par le renommer PostRepository => PostRepositoryDoctrine.
Cette instance de repository est donc celle qui utilisera l'ORM Doctrine.
Puis, vidons notre PostRepositoryDoctrine de tout son code superflu, et implémentons cette nouvelle interface :
<?php declare(strict_types=1); namespace App\Repository; use App\Entity\Post; class PostRepositoryDoctrine implements PostRepositoryInterface { public function store(Post $post): void { // TODO: Implement store() method. } public function find(int $id): ?Post { // TODO: Implement find() method. } public function findPostsAboutPhp(): array { // TODO: Implement findPostsAboutPhp() method. } }
Top ! Et si vous souhaitez changer d'ORM, ou utiliser l'ODM de Doctrine (pour utiliser plutôt MongoDB), il vous suffira de créer un nouveau repository.
Par exemple, un PostRepositoryDocument pour l'ODM, et y implémenter le code nécessaire pour intéragir avec les fonctions de l'ODM.
C'est ce que nous nous apprêtons à faire maintenant avec notre PostRepositoryDoctrine.
Reprenons ce dernier, et ajoutons ce code dans le corps de la classe :
use App\Entity\Post; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ObjectRepository; class PostRepositoryDoctrine implements PostRepositoryInterface { private ObjectRepository $repository; public function __construct(private readonly EntityManagerInterface $entityManager) { $this->repository = $this->entityManager->getRepository(Post::class); } public function store(Post $post): void { $this->entityManager->persist($post); } public function find(int $id): ?Post { return $this->repository->find($id); } public function findPostsAboutPhp(): array { // TODO: Implement findPostsAboutPhp() method. } }
Et voilà, au lieu d'étendre l'EntityRepository de Doctrine, on injecte l'EntityManager dans notre constructeur.
Puis, dans une propriété privée $repository, on récupère une instance de repository Doctrine de type Post::class qui sera notre objet proxy entre nos méthodes et celles de Doctrine.
Note
L'interface ObjectRepository de Doctrine sera ici implémentée automatiquement par son EntityRepository.
Petit détail important, souvenez-vous du code généré dans la classe Post, notamment l'attribut de la classe :
#[ORM\Entity(repositoryClass: PostRepositoryDoctrine::class)] class Post // ...
Il vous faudra vous débarrasser du paramètre repositoryClass de l'attribut, car ce dernier attend une classe de type EntityRepository (de Doctrine).
Or notre classe n'hérite plus de cette dernière.
Ce n'est pas vraiment un problème car vous utiliserez normalement de toute manière l'injection de dépendance pour utiliser votre repository là où vous en avez besoin, plutôt que la méthode getRepository(Post::class).
Et voilà ! Vous disposez à présent d'un repository tout propre qui utilise la composition, et n'implémente que les méthodes Doctrine dont vous avez réellement besoin.
...Mais ce n'est pas tout à fait terminé.
En effet, que se passe-t-il si je veux ajouter une nouvelle entité, par exemple un User ? Mettons que cette classe n'a besoin que de la méthode find.
Créons ensemble cette entité avec la commande make:entity avec seulement un name pour attribut.
Maintenant reprenons la même logique de refacto pour son repository. Maintenant que l'on est rôdé, ça devrait être assez rapide.
À la fin, vous devriez avoir une interface UserRepositoryInterface, ainsi qu'un repository qui ressemble à cela :
<?php declare(strict_types=1); namespace App\Repository; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ObjectRepository; class UserRepositoryDoctrine implements UserRepositoryInterface { private ObjectRepository $repository; public function __construct(private readonly EntityManagerInterface $entityManager) { $this->repository = $this->entityManager->getRepository(User::class); } public function find(int $id): ?User { return $this->repository->find($id); } public function store(User $user): void { $this->entityManager->persist($user); } }
Bon, comme on peut le voir, ça commence à faire pas mal de code en doublon. Pour peu que toutes vos entités aient besoin d'un repository avec les méthodes de base find, store, save, remove, ...on commence à faire pas mal de duplication de code.
Et c'est à partir de là que nous allons mettre en place... de l'héritage !
Question
Mais... On vient de faire tout ça pour se débarrasser de l'héritage et faire de la composition... Pourquoi ?!
Promis je ne me moque pas de vous. Se débarrasser de l'héritage du ServiceEntityRepository de Doctrine, c'était surtout se débarrasser d'une tonne de code superflu, non voulu, et surtout inconnu, qui se retrouvait dans votre classe.
Maintenant que nous avons fait le ménage, rien ne nous empêche de factoriser notre propre code pour éviter de se répéter.
Pour commencer, supprimons de nos repositories toutes ces méthodes génériques dont on vient de parler (find, store...), car nous allons les définir à plus haut niveau.
Dans notre exemple, UserRepositoryInterface se retrouve vide, et notre PostRepositoryInterface va finalement ressembler à cela :
<?php declare(strict_types=1); namespace App\Repository; interface PostRepositoryInterface { public function findPostsAboutPhp(): array; }
Important
On ne garde que les méthodes spécifiques de nos repositories, comme notre findPostsAboutPhp()
Adaptez bien en conséquence le code des classes implémentant ces interfaces.
Puis, créons à présent un repository de base, que nous appellerons BaseRepositoryDoctrine, qui contiendra tout le code en commun avec tous nos autres repositories.
Comme nous sommes consciencieux, créons d'abord son interface :
<?php declare(strict_types=1); namespace App\Repository; interface BaseRepositoryInterface { public function store(object $object): void; public function find(int $id): ?object; }
C'est donc ici que l'on remet nos méthodes génériques
Cette interface pourra être implémentée par n'importe quel ORM ; ici c'est un repository Doctrine que nous souhaitons créer :
<?php declare(strict_types=1); namespace App\Repository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ObjectRepository; abstract class BaseRepositoryDoctrine extends BaseRepositoryInterface { private ObjectRepository $repository; public function __construct(protected readonly EntityManagerInterface $entityManager) { $this->repository = // ?? } public function store(/* Quel type pour l'objet à insérer ? */): void { $this->entityManager->persist($object); } public function find(int $id) // Typage de retour ? { return $this->repository->find($id); } }
Et le tour est joué, tous nos repositories Doctrine n'auront qu'à hériter de cette classe pour posséder ces méthodes génériques.
Plusieurs remarques :
- Notre classe est abstraite car elle n'est pas censée être instanciée. Il s'agit juste d'un squelette, qui sera étendu par nos réels repositories.
- On ne sait pas encore comment instancier notre propriété
$repositoryqui est censée être une instance du repository Doctrine lié à notre entité. Or, ici cette classe abstraite ne sait pas par quel repository elle sera utilisée. - Quel typage pour l'argument de la méthode
storequi reçoit le nouvel objet à instancier ? - Même problème pour le type de retour de notre fonction
find, nous ne savons pas à l'avance quel objet retourner.
Pour les problèmes de typage cités ci-dessus, deux solutions.
Pour la première, on va modifier un peu le constructeur, pour qu'il accepte un argument $className.
// BaseRepositoryDoctrine.php // ... public function __construct(protected EntityManagerInterface $entityManager, string $className) { $this->repository = $this->entityManager->getRepository($className); } // ...
Puis, dans le PostRepositoryDoctrine, on appelle le constructeur parent en spécifiant la bonne entité (ici, Post) :
class PostRepositoryDoctrine extends BaseRepositoryDoctrine implements PostRepositoryInterface { public function __construct(protected EntityManagerInterface $entityManager) { parent::__construct($entityManager, Post::class); } }
Dans cette classe nous avons plus tôt supprimé toutes les méthodes déjà implémentées dans la classe abstraite, nous n'avons donc plus que ce constructeur et la méthode findPostsAboutPhp().
On y ajoutera d'autres méthodes uniquement si elles sont spécifiques à ce repository.
Question
Quid de notre problème de type dans les méthodes de la classe abstraite ?
On y vient.
Et comme du code vaut mille mots, voilà la solution, attention les yeux :
// BaseRepositoryDoctrine.php // ... public function store(object $object): void { $this->entityManager->persist($object); } public function find(int $id): ?object { return $this->repository->find($id); } // ...
Ah ! Un typage object! Vous l'aviez vu venir ? Certains crient peut-être au scandale, et ils auraient sûrement raison : en l'état actuel, nous sommes passés d'un typage fort à un typage vraiment bancal. N'importe quel object ferait l'affaire ici.
En réalité, c'est là que la magie du typage générique va faire son affaire. On en a fini avec notre refactoring, vous pouvez souffler un coup.
C'est bon ? Alors rendez-vous dans la prochaine section pour régler nos problèmes de types, et ainsi devenir un sorcier qui maîtrise les generics.
Typage générique avec PHPStan
Nous y voilà enfin ! Je vous conseille de faire une pause pour aller lire l'article sur le typage générique en PHP pour un peu de théorie sur le pourquoi du comment des generics.
Ça y est ? Alors c'est parti !
Avant toute chose, modifiez votre fichier phpstan.neon pour qu'il ressemble à cela :
parameters: level: max paths: - bin/ - config/ - public/ - src/
Puis lançons notre commande PHPStan pour voir ce qu'il en retourne :
$ ./vendor/bin/phpstan
Vous devriez avoir tout plein d'erreurs. Il est l'heure de se retrousser les manches et de s'en occuper.
Tout d'abord, il faut créer notre nouveau type générique, que nous nommons par convention T.
Avec le tag @template, nous le déclarons au plus haut niveau possible de notre imbrication de repositories, ici BaseRepositoryDoctrine :
/** @template T of object */ abstract class BaseRepositoryDoctrine { // ... }
...et son interface BaseRepositoryInterface :
<?php declare(strict_types=1); namespace App\Repository; /** @template T of object */ interface BaseRepositoryInterface { // ... }
La notation of object signifie que notre type T est quoiqu'il arrive de type object.
À présent, typons le corps de notre BaseRepositoryDoctrine pas-à-pas.
Commençons avec la propriété Repository :
/** @var ObjectRepository<T> */ protected ObjectRepository $repository;
Cela est possible car l'interface ObjectRepository contient elle même un tag @template T of object. Ce faisant, nous indiquons à notre analyseur que nous remplaçons le type T par le type que nous définissons entre les chevrons. Ici, nous avons remis notre T à nous. Aucun changement me direz-vous.
Mais vous imaginez bien que le tour de magie n'est pas terminé.
Ajoutons les tags sur nos méthodes :
/** @param T $object */ public function store(object $object): void { $this->entityManager->persist($object); } /** @return ?T */ public function find(int $id): ?object { return $this->repository->find($id); }
Taggez également les méthodes de la
BaseRepositoryInterfacede la même manière !
Ici nous indiquons à notre cher PHPStan de remplacer nos types object par notre nouveau type T. Cela fonctionne car rappelez-vous, T ne peut de toute manière n'être qu'un object.
Enfin, dernière petite astuce, vous pouvez faire ceci au niveau du constructeur :
/** @param class-string<T> $className */ public function __construct(protected EntityManagerInterface $entityManager, string $className) { $this->repository = $this->entityManager->getRepository($className); }
Le type class-string permet de limiter le type string à des valeurs très précises : ce ne pourra être que des noms de classe valides, pas une chaîne de caractère classique. Or c'est bien ce que l'on veut ici ; un nom de classe pour créer la bonne instance de repository. Vous trouverez la doc de ce tag ici.
Important
Petit rappel à mi-parcours, toutes ces vérifications sont faites seulement statiquement lorsque vous lancez votre commande PHPStan. À l'exécution du code, rien de tout cela n'est interprété et n'importe quelle chaîne de caractère peut être passée ici. D'où l'importance d'avoir une CI stricte qui vous bloque à la moindre erreur d'analyse statique
C'est bon, notre repository de base a été générisé, nous pouvons à présent en tirer profit dans nos repositories Doctrine.
Par exemple avec le PostRepositoryDoctrine :
/** @extends BaseRepositoryDoctrine<Post> */ class PostRepositoryDoctrine extends BaseRepositoryDoctrine implements PostRepositoryInterface { public function __construct(protected EntityManagerInterface $entityManager) { parent::__construct($entityManager, Post::class); } /** @return array<Post> */ public function findPostsAboutPhp(): array { // Whatever... return []; } }
Le seul changement utile et important ici est le tag @extends juste au-dessus de la classe. C'est ici que tout ce passe.
On indique ici que nous étendons notre BaseRepositoryDoctrine, mais avec une info supplémentaire, la présence des chevrons <Post>. En faisant cela, on explique à PHPStan que nous souhaitons remplacer, dans cette classe, tous nos types génériques T par Post. Et par User dans notre Repository User en faisant la manipulation équivalente.
Notez également la notation @return array<Post> au-dessus de la seule méthode de notre repository. Cela permet d'indiquer que nous ne pouvons retourner qu'un tableau composé d'objets Post. En fonction de comment vous implémentez la méthode, PHPStan vous remonte une erreur si ce n'est pas le cas.
Note
array<Post> peut aussi être noté Post[] si vous préférez.
Testons d'ailleurs si tout ce petit monde fonctionne correctement. Créons un Controller, et mettons-y ce code :
<?php declare(strict_types=1); namespace App\Controller; use App\Entity\Post; use App\Repository\UserRepositoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class BaseController { #[Route(path: '/', name: 'index', methods: [Request::METHOD_GET])] public function index(UserRepositoryInterface $userRepository): Response { $post = new Post(); $userRepository->store($post); return new Response(); } }
Puis, lançons notre commande PHPStan. Vous devriez recevoir cette erreur :
$ Parameter 1 $object of method App\Repository\BaseRepositoryInterface<App\Entity\User>::store() expects App\Entity\User, App\Entity\Post given.
Eh oui, car nous essayons de passer un objet de type Post dans la méthode store() du UserRepository. PHPStan est capable de le détecter grâce à nos quelques annotations, et nous produit cette erreur.
Remplacez l'argument par un objet de type User, et tout rentre dans l'ordre.
Et voilà, vous en avez fini avec les types génériques !
Conclusion
Ouf ! Et voilà ! Vous êtes arrivés au bout de ce Codelabs, félicitations ! Pour rappel, vous trouverez le code de ce Codelabs ici sur mon Github, n'hésitez pas à y jeter un oeil.
Vous êtes à présent, je l'espère, plus familier avec le concept de composition over inheritance, et le typage générique en PHP n'a plus aucun secret pour vous.
Je vous rajoute quelques sources ici qui m'ont aidé à écrire ce Codelabs :
Merci beaucoup d'avoir suivi jusqu'ici, et à très vite !




