Chapitre 6.1 — ORM & entités
Partie 6 : Doctrine ORM — Chapitre 1/7 Version de référence : Doctrine ORM 3 / Symfony 7.4.
⏱️ TL;DR
- Un ORM (Object-Relational Mapping) fait correspondre des objets PHP à des lignes de table. Symfony utilise Doctrine ORM.
- Doctrine suit le pattern Data Mapper : une entité est un objet PHP pur (aucune connaissance de la base) ; c’est l’
EntityManagerqui la persiste. À l’opposé d’Active Record (Eloquent/Laravel) où$user->save()vit dans l’entité.- Une entité = une classe avec des attributs
#[ORM\Entity],#[ORM\Id],#[ORM\Column]. On la génère avecmake:entity.$em->persist($entity)planifie l’insertion ;$em->flush()exécute réellement les requêtes (dans une transaction). L’Unit of Work suit les changements.- Les entités ne sont pas
readonly(Doctrine doit les hydrater/proxifier) : on utilise des propriétés privées + getters/setters.- Pour un dev Next.js : Doctrine ≈ Prisma/TypeORM, version Data Mapper — l’entité est le « model », l’
EntityManagerjoue le rôle du client Prisma (persistance séparée).
🎯 Objectifs
- Comprendre ce qu’est un ORM et le pattern Data Mapper.
- Créer une entité mappée par attributs.
- Manier
persist/flush/removeet comprendre l’Unit of Work. - Récupérer des entités via le repository.
1. À quoi sert un ORM
Sans ORM, on écrit du SQL et on transforme à la main les lignes en objets (et inversement). Un ORM automatise ce va-et-vient : vous manipulez des objets PHP, il génère le SQL et hydrate les résultats en objets.
💡 Pour un dev Next.js : Doctrine ≈ Prisma ou TypeORM. Vous décrivez vos modèles, l’ORM parle à la base. La particularité de Doctrine est d’être Data Mapper (comme TypeORM en mode DataMapper), pas Active Record.
2. Data Mapper vs Active Record (le choix de Doctrine)
C’est une distinction structurante (déjà évoquée au chapitre 1.4) :
| Data Mapper (Doctrine) | Active Record (Eloquent/Laravel) | |
|---|---|---|
| L’entité | Objet PHP pur, sans logique de persistance | « sait » se sauver : $user->save() |
| Persistance | Gérée par un EntityManager externe | Intégrée à l’entité |
| Couplage à la base | Faible (entité testable seule) | Fort (entité liée à la base) |
| Verbosité initiale | Un peu plus | Un peu moins |
Concrètement, chez Doctrine, une entité Booking ignore qu’une base existe. On la crée en mémoire, puis on demande à l’EntityManager de la persister. Ça garde vos objets métier découplés et testables — l’esprit Symfony.
3. Créer une entité
Le plus rapide : le Maker (interactif).
php bin/console make:entity BookingIl demande les champs (nom, type, nullable…) et génère l’entité et son repository. Voici à quoi ressemble une entité Booking (simplifiée) écrite/enrichie à la main :
// src/Entity/Booking.php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\BookingStatus;
use App\Repository\BookingRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BookingRepository::class)]
#[ORM\Table(name: 'bookings')]
class Booking
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $customerEmail;
#[ORM\Column]
private \DateTimeImmutable $start;
#[ORM\Column(enumType: BookingStatus::class)]
private BookingStatus $status = BookingStatus::Pending;
#[ORM\Column(options: ['default' => 1])]
private int $seats = 1;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getCustomerEmail(): string { return $this->customerEmail; }
public function setCustomerEmail(string $email): static { $this->customerEmail = $email; return $this; }
public function getStatus(): BookingStatus { return $this->status; }
public function setStatus(BookingStatus $status): static { $this->status = $status; return $this; }
public function getStart(): \DateTimeImmutable { return $this->start; }
public function setStart(\DateTimeImmutable $start): static { $this->start = $start; return $this; }
public function getSeats(): int { return $this->seats; }
public function setSeats(int $seats): static { $this->seats = $seats; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}Points à noter :
#[ORM\Entity(...)]: cette classe est une entité, gérée parBookingRepository.#[ORM\Id]+#[ORM\GeneratedValue]: clé primaire auto-incrémentée. L’idest?int(null tant que non persisté).#[ORM\Column]: chaque colonne. Le type SQL est déduit du type PHP (string→ varchar,\DateTimeImmutable→ timestamp,int→ integer).enumType:mappe l’enumBookingStatus: en base, la colonne stocke la valeur ('confirmed'), en PHP vous avez l’enum (chapitre 2.3).
4. Les types de colonnes courants
| Type PHP / option | Colonne SQL | Note |
|---|---|---|
string (+ length) | VARCHAR | #[ORM\Column(length: 255)] |
int | INTEGER | |
bool | BOOLEAN | |
\DateTimeImmutable | TIMESTAMP | préférez l’immuable au \DateTime |
float | DOUBLE | pour de la monnaie, évitez → int (centimes) ou decimal |
type: 'decimal' | NUMERIC | montants exacts (#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]) |
type: 'text' | TEXT | longues chaînes |
type: 'json' | JSON/JSONB | tableaux/objets (natif PostgreSQL) |
enumType: X::class | selon backing | mappe une enum backed |
nullable: true | colonne NULL | + type PHP ?T |
⚠️ Piège monnaie : ne stockez jamais un montant en
float(erreurs d’arrondi binaire). Utilisez uninten centimes (1990= 19,90 €) ou le typedecimal(chaîne exacte). Doctrine renvoiedecimalsous forme destringpour préserver la précision — pensez à le caster explicitement.
5. Persister : l’EntityManager et l’Unit of Work
L’EntityManager est le point d’entrée de la persistance. Deux méthodes clés :
persist($entity): dit à Doctrine « gère cette nouvelle entité » (planifie une insertion). N’écrit rien en base encore.flush(): exécute toutes les opérations en attente (INSERT/UPDATE/DELETE), dans une transaction.
use Doctrine\ORM\EntityManagerInterface;
final class BookingCreator
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function create(CreateBookingInput $input): Booking
{
$booking = new Booking();
$booking->setCustomerEmail($input->customerEmail);
$booking->setStart($input->start);
$booking->setSeats($input->seats);
$this->em->persist($booking); // planifie l'INSERT
$this->em->flush(); // exécute (dans une transaction)
return $booking; // $booking->getId() est désormais rempli
}
}Le point subtil : Doctrine tient un Unit of Work — il observe les entités gérées et, au flush(), calcule automatiquement ce qui a changé. Modifier une entité déjà en base ne nécessite pas de persist() : il suffit de la charger, la modifier, et flush().
$booking = $repository->find($id); // entité « gérée »
$booking->setStatus(BookingStatus::Confirmed);
$em->flush(); // Doctrine détecte le changement → UPDATE⚠️ Piège du débutant Doctrine : appeler
flush()après chaquepersist()dans une boucle.flush()déclenche une transaction et des requêtes ; en boucle, c’est catastrophique pour les perfs. Persistez plusieurs entités, puis un seulflush()à la fin. (Pour de très gros volumes, on flush par lots — chapitre 6.6.)
💡 Pour un dev Next.js :
persist+flush≈ préparer des opérations puisawait tx.commit(). La nuance Doctrine : le suivi automatique des changements (Unit of Work) fait qu’un simplesetX()sur une entité chargée génère l’UPDATE au flush, sans appel explicite de sauvegarde. C’est déroutant au début, très pratique ensuite.
6. Récupérer des entités : le repository
Chaque entité a un repository (généré à côté). Il offre des méthodes de base sans écrire de requête :
$repo = $em->getRepository(Booking::class); // ou injecter BookingRepository
$repo->find(42); // par clé primaire (ou null)
$repo->findAll(); // toutes (attention aux gros volumes)
$repo->findBy(['status' => BookingStatus::Confirmed], ['start' => 'ASC'], 20);
$repo->findOneBy(['customerEmail' => 'a@b.c']);
$repo->count(['status' => BookingStatus::Pending]);On y ajoute ses propres méthodes de requête (DQL/QueryBuilder) — c’est l’objet du chapitre 6.3. Le repository est un service : on l’injecte (comme tout le reste).
7. Doctrine ORM 3 : ce qui a changé
⚡ Depuis Symfony 5 : à votre époque, le mapping se faisait souvent en annotations (
@ORM\Column) ou parfois en YAML/XML. Avec Doctrine ORM 3 (Symfony 7.4), le mapping par attributs est la norme ; le mapping YAML/XML est déprécié, etdoctrine/annotationsn’est plus nécessaire. Autre évolution : DBAL 4 (la couche basse) et une gestion plus stricte des types. Si vos souvenirs sont « des@ORMdans des commentaires », le code moderne est en#[ORM\...].
🐘 Rappel PHP 8+ : les entités ne peuvent pas être
readonlyni utiliser des propriétésreadonlysur les champs mappés — Doctrine doit pouvoir hydrater (remplir) les objets et créer des proxies (chapitre 6.2), ce qui exige des propriétés modifiables. Réservezreadonlyet l’immuabilité aux DTO et value objects (chapitre 2.5), pas aux entités.
✏️ Exercices
Exercice 1. Créez une entité Provider (prestataire) avec : id auto, name (string, requis), slug (string, unique), createdAt (datetime immuable). Donnez le squelette avec attributs.
✅ Solution
#[ORM\Entity(repositoryClass: ProviderRepository::class)]
#[ORM\Table(name: 'providers')]
class Provider
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255, unique: true)]
private string $slug;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct() { $this->createdAt = new \DateTimeImmutable(); }
// + getters/setters
}unique: true sur slug crée un index unique en base.
Exercice 2. Ce code fait un flush() dans une boucle de 1000 itérations. Pourquoi est-ce un problème, et comment corriger ?
foreach ($rows as $row) {
$b = new Booking(); $b->setCustomerEmail($row['email']);
$em->persist($b);
$em->flush(); // ← dans la boucle
}✅ Solution
Chaque flush() ouvre une transaction et exécute des requêtes : 1000 flushs = 1000 transactions, très lent. Correction : persist() dans la boucle, un seul flush() à la fin (ou, pour de très gros volumes, flusher par lots de ~100 et clear() l’EntityManager pour libérer la mémoire — chapitre 6.6) :
foreach ($rows as $i => $row) {
$b = new Booking(); $b->setCustomerEmail($row['email']);
$em->persist($b);
if ($i % 100 === 0) { $em->flush(); $em->clear(); }
}
$em->flush();Exercice 3. Pourquoi une entité ne doit-elle pas stocker un montant en float ? Quelle(s) alternative(s) ?
✅ Solution
Le float (binaire) introduit des erreurs d’arrondi inacceptables pour de la monnaie. Alternatives : stocker un int en centimes (1990 = 19,90 €), ou utiliser le type decimal (precision/scale) qui garde une précision exacte (Doctrine le renvoie en string). Pour BookLab (paiements), on utilisera des entiers en centimes.
🧠 Quiz de révision
1. Quel pattern de persistance Doctrine suit-il, et qu’implique-t-il ?
Data Mapper : l’entité est un objet PHP pur (sans logique de base) et c’est l’EntityManager qui la persiste — d’où un fort découplage et une meilleure testabilité (vs Active Record où l’entité se sauve elle-même).
2. Différence entre persist() et flush() ?
persist() et flush() ?persist() planifie la gestion/insertion d’une entité (rien en base) ; flush() exécute toutes les opérations en attente dans une transaction (INSERT/UPDATE/DELETE).
3. Faut-il persist() une entité déjà chargée qu’on a modifiée ?
persist() une entité déjà chargée qu’on a modifiée ?Non : l’Unit of Work suit les entités gérées ; un setX() suivi de flush() génère l’UPDATE automatiquement.
4. Peut-on rendre une entité readonly ?
readonly ?Non : Doctrine doit hydrater et proxifier les entités (propriétés modifiables requises). readonly est réservé aux DTO/value objects.
5. Comment stocke-t-on une enum backed dans une colonne ?
Avec #[ORM\Column(enumType: BookingStatus::class)] : la base stocke la valeur de l’enum, Doctrine reconstruit l’enum côté PHP.
Prochain chapitre : 6.2 — Les relations — ManyToOne, OneToMany, ManyToMany, OneToOne, côté propriétaire et inverse.