Symfony et la persistance bi-directionnelle des relations ManyToMany
Dans cet article, je vais vous présenter ma méthode pour rendre persistante de façon bi-directionnelle les relations ManyToMany de Symfony.
Le comportement normal d’une relation ManyToMany est d’avoir une entité maître et l’autre esclave de celle ci. En d’autre terme, lorsque l’on persiste les données il n’y a qu’un seul sens qui est sauvegardé dans la base de données.
Contexte technique
Ce tutoriel sera basé sur
- Symfony 5.4
- Mysql
- Windows 11
- Wamp64
Le seul package ajouté sera symfony/apache-pack afin d’avoir la toolbar de symfony.
L’ensemble des codes sources présentés dans cet article seront sur ce repository Github :
https://github.com/esauvage1978/Blog_manytomany_reversible
Je vous invite à suivre les consignes du fichier Readme.md afin de configurer le projet.
Définition des entités pour la relation ManyToMany de Symfony
Pour cet exemple, nous allons prendre 2 entités : Student et Event avec une relation ManyToMany.
- 1 étudiant peut participer à plusieurs événements
- 1 événement peut avoir plusieurs étudiants qui y participent
Les entités sont créées à l’aide des commandes :
php bin/console make:entity Studentphp bin/console make:entity EventCe qui produit le code suivant :
<?php
namespace App\Entity;
use App\Repository\EventRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=EventRepository::class)
*/
class Event
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $name;
/**
* @ORM\Column(type="datetime_immutable")
*/
private $EventAt;
/**
* @ORM\ManyToMany(targetEntity=Student::class, inversedBy="events")
*/
private $students;
public function __construct()
{
$this->students = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEventAt(): ?\DateTimeImmutable
{
return $this->EventAt;
}
public function setEventAt(\DateTimeImmutable $EventAt): self
{
$this->EventAt = $EventAt;
return $this;
}
/**
* @return Collection<int, Student>
*/
public function getStudents(): Collection
{
return $this->students;
}
public function addStudent(Student $student): self
{
if (!$this->students->contains($student)) {
$this->students[] = $student;
}
return $this;
}
public function removeStudent(Student $student): self
{
$this->students->removeElement($student);
return $this;
}
}<?php
namespace App\Entity;
use App\Repository\StudentRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=StudentRepository::class)
*/
class Student
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $name;
/**
* @ORM\ManyToMany(targetEntity=Event::class, mappedBy="students")
*/
private $events;
public function __construct()
{
$this->events = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* @return Collection<int, Event>
*/
public function getEvents(): Collection
{
return $this->events;
}
public function addEvent(Event $event): self
{
if (!$this->events->contains($event)) {
$this->events[] = $event;
$event->addStudent($this);
}
return $this;
}
public function removeEvent(Event $event): self
{
if ($this->events->removeElement($event)) {
$event->removeStudent($this);
}
return $this;
}
}
Création des CRUD sur les entités Symfony
J’ai utilisé les commandes suivantes pour générer les crud. Pour les non initiés, CRUD signifie Create Read Update et Delele.
php bin/console make:crud Studentphp bin/console make:crud EventAfin afficher le nombre d’association, j’ai modifié les vues (Voir repository)


Mise en oeuvre de la persistance bi-directionnelle de la relation ManyToMany
Maintenant que nos 2 entités sont fonctionnelles, on peut facilement constater que lorsque je modifie un événement, les étudiants ajoutés sont persistés. Cependant, l’inverse ne fonctionne pas.
Nous allons résoudre ce problème au moment de la modification de l’entité Student. Le principe est le suivant :
- Récupération de tous les événements de l’étudiant modifié,
- Comparaison entre cette liste et la liste récupérée dans le formulaire,
- Et enfin ajout et retrait des différences.
Dans EventRepository, on va créer une nouvelle requête pour récupérer tous les événements d’un étudiant. Par habitude, j’ai créé des alias de table au préalable pour éviter les erreurs sur les noms de champs identiques.
public function findAllForStudent(string $studentId)
{
return $this->createQueryBuilder(self::ALIAS)
->select(self::ALIAS)
->leftJoin(self::ALIAS . '.students' , StudentRepository::ALIAS )
->where(StudentRepository::ALIAS . '.id = :student')
->setParameter('student', $studentId)
->orderBy(self::ALIAS . '.name', 'ASC')
->getQuery()
->getResult();
}Pour effectuer la comparaison, j’ai créé une classe générique dans un dossier Helper
<?php
namespace App\Helper;
/**
* @author Emmanuel SAUVAGE <emmanuel.sauvage@live.fr>
* @version 1.0.0
*/
class ArrayDiff
{
/**
* @var array
*/
private $oldData;
/**
* @var array
*/
private $newData;
public function __construct(array $oldData=[],array $newData=[])
{
$this->oldData=$oldData;
$this->newData=$newData;
}
public function getDeleteDiff()
{
return array_udiff_assoc(
$this->oldData,
$this->newData,
function($a, $b) {return $a === $b ? 0 : 1;}
);
}
public function getInsertDiff()
{
return array_udiff_assoc(
$this->newData,
$this->oldData,
function($a, $b) {return $a === $b ? 0 : 1;}
);
}
}Et pour terminer, j’ai procédé à la comparaison dans la fonction edit du contrôleur Student
/**
* @Route("/{id}/edit", name="app_student_edit", methods={"GET", "POST"})
*/
public function edit(Request $request, Student $student, StudentRepository $studentRepository, EventRepository $eventRepository): Response
{
$form = $this->createForm(StudentType::class, $student);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->setRelation(
$student,
$eventRepository->findAllForStudent($student->getId()),
$student->getEvents()
);
$studentRepository->add($student);
return $this->redirectToRoute('app_student_index', [], Response::HTTP_SEE_OTHER);
}
return $this->renderForm('student/edit.html.twig', [
'student' => $student,
'form' => $form,
]);
}
private function setRelation(Student $student, $entitysOld, $entitysNew)
{
$em = new ArrayDiff($entitysOld, $entitysNew->toArray());
foreach ($em->getDeleteDiff() as $entity) {
$entity->removeStudent($student);
}
foreach ($em->getInsertDiff() as $entity) {
$entity->addStudent($student);
}
}Pour conclure
La mise en oeuvre de la persistance bi-directionnelle demande un peu de travail.
Dans cet exemple, je n’ai pas respecté mes habitudes de travail à savoir de mettre le minimum de code dans les contrôleurs. La bonne pratique serait d’extraire le code additionnel du contrôleur en utilisant par exemple un manager. Cependant le but était de mettre en œuvre la persistance bi-directionnelle sur les entités avec une relation ManyToMany.


Bonjour, j’ai effectué les ajout de code afin de pouvoir modifier mes entité d’un coté comme de l’autre mais malheureusement j’ai ce message qui s’affiche Attempted to call an undefined method named « toArray » of class.
j’ai voulu définir une fonction toArray() mais ceci ne fait rien =.
pouvez vous me dire si il y a une solution a ce problème car je sèche.
merci.