Skip to Content

Chapitre 6.2 — Les relations

Partie 6 : Doctrine ORM — Chapitre 2/7 Version de référence : Doctrine ORM 3 / Symfony 7.4.

⏱️ TL;DR

  • Quatre types de relations : ManyToOne, OneToMany, ManyToMany, OneToOne. La paire la plus fréquente : ManyToOne ↔ OneToMany.
  • Côté propriétaire (owning) = celui qui porte la clé étrangère en base. Pour ManyToOne/OneToMany, c’est toujours le côté ManyToOne. Le côté OneToMany est inverse (mappedBy).
  • Une relation « à plusieurs » est une Collection Doctrine (souvent une ArrayCollection) ; on maintient les deux côtés synchronisés dans les méthodes addX/removeX.
  • cascade (persist/remove) et orphanRemoval automatisent la persistance/suppression des entités liées.
  • Par défaut, les relations sont chargées paresseusement (LAZY) via des proxies — attention au N+1 (chapitre 6.6).
  • Pour un dev Next.js : ce sont les relations Prisma (@relation), mais avec la subtilité « côté propriétaire = porteur de la FK » à garder en tête.

🎯 Objectifs

  • Déclarer les quatre types de relations avec les attributs Doctrine.
  • Identifier le côté propriétaire (porteur de la FK) et le côté inverse.
  • Maintenir la cohérence des deux côtés d’une collection.
  • Utiliser cascade et orphanRemoval à bon escient.

1. Les relations de BookLab

Rappel du modèle qu’on construit :

  • Un Provider propose plusieurs Service (OneToMany / ManyToOne).
  • Un Service a plusieurs Slot (créneaux).
  • Un Slot reçoit plusieurs Booking.
  • Un User fait plusieurs Booking.
  • Un Booking a au plus un Payment (OneToOne).

2. ManyToOne / OneToMany : la paire fondamentale

C’est la relation la plus courante. Prenons Service → Slot : un Service a plusieurs Slot, chaque Slot appartient à un Service.

Côté Slot (ManyToOne — le côté PROPRIÉTAIRE, porteur de la FK) :

// src/Entity/Slot.php #[ORM\Entity] class Slot { #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id = null; #[ORM\ManyToOne(inversedBy: 'slots')] #[ORM\JoinColumn(nullable: false)] // colonne service_id NOT NULL en base private ?Service $service = null; public function getService(): ?Service { return $this->service; } public function setService(?Service $service): static { $this->service = $service; return $this; } }

Côté Service (OneToMany — le côté INVERSE) :

// src/Entity/Service.php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; #[ORM\Entity] class Service { #[ORM\Id, ORM\GeneratedValue, ORM\Column] private ?int $id = null; /** @var Collection<int, Slot> */ #[ORM\OneToMany(targetEntity: Slot::class, mappedBy: 'service', orphanRemoval: true)] private Collection $slots; public function __construct() { $this->slots = new ArrayCollection(); // ⚠️ initialiser dans le constructeur } /** @return Collection<int, Slot> */ public function getSlots(): Collection { return $this->slots; } public function addSlot(Slot $slot): static { if (!$this->slots->contains($slot)) { $this->slots->add($slot); $slot->setService($this); // ← synchronise l'AUTRE côté } return $this; } public function removeSlot(Slot $slot): static { if ($this->slots->removeElement($slot)) { if ($slot->getService() === $this) { $slot->setService(null); // ← désynchronise proprement } } return $this; } }

Le Maker (make:entity, en ajoutant une relation) génère exactement ce code, y compris les helpers addSlot/removeSlot. Comprenez-le, ne le récrivez pas à la main.

⚠️ Piège n°1 des relations : oublier de synchroniser les deux côtés. En mémoire, $service->addSlot($slot) et $slot->setService($service) doivent tous deux être vrais, sinon votre objet en mémoire est incohérent. Les helpers addSlot/removeSlot s’en chargent — utilisez-les plutôt que de manipuler la collection directement.

⚠️ Piège n°2 : oublier d’initialiser la collection (new ArrayCollection()) dans le constructeur → erreur « null » au premier add. Le Maker le fait ; à la main, ne l’oubliez pas.

3. Côté propriétaire vs inverse : la règle

C’est le concept qui déroute. En base relationnelle, une relation « un-à-plusieurs » se matérialise par une seule clé étrangère, du côté « plusieurs ».

Donc :

  • Côté propriétaire = celui qui porte la FK = le côté ManyToOne (Slot). C’est lui qui utilise inversedBy.
  • Côté inverse = le côté OneToMany (Service), qui utilise mappedBy.

Conséquence pratique cruciale : Doctrine ne regarde que le côté propriétaire pour décider quoi écrire en base. Si vous modifiez seulement le côté inverse ($service->getSlots()->add($slot)) sans définir $slot->setService($service), rien n’est persisté. D’où l’importance des helpers qui synchronisent les deux côtés.

💡 Pour un dev Next.js : chez Prisma, vous déclarez la relation des deux côtés et Prisma sait où est la FK. Chez Doctrine, vous devez savoir que le côté ManyToOne (celui qui a la FK) est le propriétaire, et que c’est lui qui compte pour la persistance. Retenez : « la FK est du côté “plusieurs”, et c’est le côté qui décide ».

4. ManyToMany

Pas de « côté plusieurs » unique : une table de jointure relie les deux. Exemple hypothétique : un Service a plusieurs Tag, un Tag concerne plusieurs Service.

// côté propriétaire (celui qui déclare la JoinTable) #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'services')] #[ORM\JoinTable(name: 'service_tags')] private Collection $tags; // côté inverse #[ORM\ManyToMany(targetEntity: Service::class, mappedBy: 'tags')] private Collection $services;

Doctrine crée et gère la table de jointure service_tags (deux FK). Là encore, on maintient les deux côtés via des helpers addTag/removeTag.

⚠️ Piège : si la relation « plusieurs-à-plusieurs » doit porter des données propres (ex. une date d’association, une quantité), une ManyToMany ne suffit pas : il faut une entité de jointure explicite (ex. Enrollment) avec deux ManyToOne + les champs supplémentaires. C’est le cas dès qu’il y a « plus qu’un simple lien ».

5. OneToOne

Une relation exclusive : un Booking a un Payment, un Payment concerne un Booking.

// côté propriétaire (Payment porte la FK booking_id) #[ORM\OneToOne(inversedBy: 'payment')] #[ORM\JoinColumn(nullable: false)] private ?Booking $booking = null; // côté inverse (Booking) #[ORM\OneToOne(mappedBy: 'booking')] private ?Payment $payment = null;

6. Cascade et orphanRemoval

Par défaut, persister un Service ne persiste pas automatiquement ses Slot liés. Deux options changent ça :

  • cascade: ['persist'] : persister le parent persiste aussi les enfants nouvellement ajoutés.
  • cascade: ['remove'] : supprimer le parent supprime les enfants.
  • orphanRemoval: true : un enfant retiré de la collection (sans être rattaché ailleurs) est supprimé de la base.
#[ORM\OneToMany(targetEntity: Slot::class, mappedBy: 'service', cascade: ['persist'], orphanRemoval: true)] private Collection $slots;

Avec ça : $service->addSlot($newSlot); $em->persist($service); $em->flush(); persiste aussi le nouveau slot ; et $service->removeSlot($slot); $em->flush(); supprime le slot devenu orphelin.

⚠️ Piège : cascade: ['remove'] et orphanRemoval sont puissants et dangereux. Supprimer un Provider avec cascade peut effacer en chaîne services, créneaux et réservations — perte de données massive. Réservez la cascade aux vraies relations de composition (l’enfant n’a pas de sens sans le parent, ex. Slot sans Service). Pour des données à conserver (réservations, paiements), préférez une suppression contrôlée (soft delete, archivage). En base, on ajoute souvent onDelete: 'RESTRICT' sur la JoinColumn pour empêcher une suppression accidentelle.

7. Chargement paresseux et proxies (aperçu)

Par défaut (fetch: 'LAZY'), accéder à $booking->getSlot()->getService() déclenche des requêtes à la demande : Doctrine renvoie un proxy (objet fantôme) qui charge les données au premier accès. C’est pratique, mais c’est la source du problème N+1 (charger 100 bookings puis leur slot un par un = 101 requêtes). On traite ça en détail au chapitre 6.6 (fetch joins). Gardez-le en tête dès maintenant.

✏️ Exercices

Exercice 1. Déclarez la relation User → Booking (un utilisateur a plusieurs réservations, une réservation appartient à un utilisateur). Quel côté est propriétaire ?

✅ Solution

Le côté propriétaire est Booking (ManyToOne, il porte la FK user_id) :

// Booking.php #[ORM\ManyToOne(inversedBy: 'bookings')] #[ORM\JoinColumn(nullable: false)] private ?User $user = null; // User.php /** @var Collection<int, Booking> */ #[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'user')] private Collection $bookings; // + new ArrayCollection() dans le constructeur

Booking (ManyToOne) est propriétaire, User (OneToMany) est inverse (mappedBy: 'user').

Exercice 2. Ce code n’écrit rien en base. Pourquoi ?

$service->getSlots()->add($slot); // on ajoute côté inverse seulement $em->persist($service); $em->flush();

✅ Solution

On a modifié seulement le côté inverse (la collection slots de Service), mais Doctrine ne persiste la relation que d’après le côté propriétaire (Slot::$service, porteur de la FK). Comme $slot->getService() est resté null, aucune FK n’est écrite. Correction : définir le côté propriétaire — $slot->setService($service) — ce que fait le helper $service->addSlot($slot). Toujours passer par les helpers addX/removeX.

Exercice 3. Vous voulez qu’un Service supprimé efface ses Slot, mais jamais ses Booking. Comment configurer les cascades ?

✅ Solution

Sur Service::$slots, mettre cascade: ['persist'] et orphanRemoval: true (ou cascade: ['remove']) pour que les créneaux suivent le service. Mais sur la relation Slot → Booking (et/ou User → Booking), ne pas mettre de cascade remove ; au contraire, protéger les réservations (par ex. onDelete: 'RESTRICT' sur la JoinColumn, ou un archivage). Ainsi supprimer un service efface ses créneaux, mais les réservations sont préservées (ou bloquent la suppression si elles existent).

🧠 Quiz de révision

1. Dans une relation ManyToOne/OneToMany, quel côté est propriétaire ?

Le côté ManyToOne (celui qui porte la clé étrangère), qui utilise inversedBy. Le côté OneToMany est inverse (mappedBy).

2. Pourquoi maintenir les deux côtés d’une relation en mémoire ?

Pour la cohérence de l’objet et parce que Doctrine ne persiste que d’après le côté propriétaire : modifier seulement l’inverse n’écrit rien. Les helpers addX/removeX synchronisent les deux.

3. Que faut-il faire au constructeur pour une relation « à plusieurs » ?

Initialiser la collection : $this->slots = new ArrayCollection(); (sinon erreur au premier add).

4. Que fait orphanRemoval: true ?

Il supprime de la base un enfant retiré de la collection du parent (devenu orphelin), sans rattachement ailleurs.

5. Quand une ManyToMany ne suffit-elle pas ?

Quand le lien doit porter ses propres données (date, quantité, statut) : il faut alors une entité de jointure explicite avec deux ManyToOne et les champs supplémentaires.


Prochain chapitre : 6.3 — Repositories, DQL & QueryBuilder — interroger la base proprement, sans SQL brut.