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
CollectionDoctrine (souvent uneArrayCollection) ; on maintient les deux côtés synchronisés dans les méthodesaddX/removeX.cascade(persist/remove) etorphanRemovalautomatisent 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
cascadeetorphanRemovalà 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 helpersaddSlot/removeSlots’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 premieradd. 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 utiliseinversedBy. - Côté inverse = le côté OneToMany (
Service), qui utilisemappedBy.
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
ManyToManyne suffit pas : il faut une entité de jointure explicite (ex.Enrollment) avec deuxManyToOne+ 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']etorphanRemovalsont puissants et dangereux. Supprimer unProvideravec 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 souventonDelete: 'RESTRICT'sur laJoinColumnpour 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 constructeurBooking (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 ?
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.