TECHNOLOGIES / Doctrine ORM
Doctrine ORM : mapping, relations et migrations Symfony
Doctrine ORM est la couche de persistance de référence dans l'écosystème Symfony. Il mappe les entités PHP sur MySQL et gère migrations, relations et requêtes via un QueryBuilder et le DQL.
Cette page couvre les patterns d'association entre entités, la déclaration des index, le DQL et les bonnes pratiques de performance pour l'équipe SmartBooster.
AVANTAGES DE L'ORM DOCTRINE
Pourquoi utiliser l'ORM Doctrine ?
Un ORM supprime la friction entre le code objet et la base relationnelle. Doctrine y ajoute un pattern Data Mapper éprouvé, une intégration Symfony native et un cycle de vie des objets entièrement géré.
Code objet, stockage relationnel : le problème qu'un ORM résout
En PHP, la logique métier vit dans des objets avec méthodes et relations. En SQL, les données vivent dans des tables avec des lignes et des jointures. Sans ORM, chaque aller-retour entre les deux mondes s'écrit à la main, requête par requête. Doctrine supprime cette traduction permanente.
Les entités ne savent pas qu'elles sont persistées
Doctrine applique le pattern Data Mapper : vos entités PHP sont des classes métier pures, sans méthode save() ni couplage avec la base. L'EntityManager gère la persistance séparément. Résultat : les entités sont testables unitairement sans connexion à une base de données.
L'Unit of Work suit chaque changement automatiquement
Doctrine maintient un registre de tous les objets chargés en mémoire. Au flush(), il calcule ce qui a changé et envoie uniquement les SQL nécessaires, dans le bon ordre, sans doublon, en tenant compte des dépendances entre entités. Pas de UPDATE systématique sur tous les champs.
MySQL, PostgreSQL ou SQLite : même code
Doctrine abstrait le moteur de base de données via des pilotes interchangeables. Le même code d'entités et de repositories tourne sur MySQL en production (possibilité d'utiliser SQLite en tests unitaires). Changer de moteur ne touche que le DSN de connexion.
Composant de référence dans l'écosystème Symfony
Doctrine ORM est l'intégration par défaut de tout projet Symfony : EntityManager auto-câblé, commandes console intégrées (doctrine:migrations:*, doctrine:fixtures:load), profiler qui affiche chaque requête et son temps d'exécution sans configuration supplémentaire.
Validation des types avant d'envoyer le SQL
Les types PHP des propriétés d'entité sont mappés sur des types SQL précis. Si une valeur ne correspond pas au type attendu, Doctrine lève une exception PHP avant d'ouvrir la moindre connexion. Les erreurs de type sont détectées au plus tôt, pas après un INSERT en base.
LES BRIQUES FONDAMENTALES
Entités et Repositories
Doctrine s'articule autour de deux objets complémentaires : l'entité qui modélise la donnée et définit le schéma, le repository qui centralise l'accès à cette donnée.
Une entité est une classe PHP annotée avec des attributs Doctrine. Elle représente une table : chaque propriété devient une colonne, chaque instance une ligne. C'est la source de vérité du schéma : Doctrine dérive le SQL depuis la classe, jamais l'inverse.
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'user')]
class User
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
#[ORM\Column(length: 180, unique: true)]
private string $email;
#[ORM\Column(length: 20)]
private string $status;
#[ORM\OneToMany(targetEntity: Order::class, mappedBy: 'user')]
private Collection $orders;
}
Les attributs définissent types, contraintes, relations et index.
doctrine:migrations:diff
compare ce modèle au schema existant et génère le SQL de migration correspondant.
Le repository est une classe dédiée à chaque entité qui centralise toutes les méthodes de récupération de données. Les controllers et services n'accèdent jamais directement à la base : ils passent par le repository, ce qui maintient la logique de requête au même endroit et simplifie les tests.
class UserRepository extends ServiceEntityRepository
{
public function findActiveByEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->where('u.email = :email')
->andWhere('u.status = :status')
->setParameter('email', $email)
->setParameter('status', 'active')
->getQuery()
->getOneOrNullResult();
}
public function findWithOrders(int $id): ?User
{
return $this->createQueryBuilder('u')
->addSelect('o')
->leftJoin('u.orders', 'o')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
} ServiceEntityRepository expose
findBy(),
findOneBy() et
findAll() par défaut.
Les méthodes personnalisées s'ajoutent dans la classe fille.
DDL ET DQL
Les deux langages que Doctrine gère pour vous
Doctrine opère à deux niveaux distincts : la définition du schéma (DDL) via les migrations, et l'interrogation des données (DQL) via un langage orienté entités PHP.
Le DDL est généré automatiquement depuis le modèle défini dans les entités :
types de colonnes, contraintes, clés étrangères et index sont tous déclarés sur les classes PHP.
La commande
doctrine:migrations:diff
calcule la différence entre le schéma de base courant et les entités, puis produit un fichier de migration versionné dans Git.
-- Généré automatiquement par doctrine:migrations:diff
CREATE TABLE user (
id INT AUTO_INCREMENT NOT NULL,
email VARCHAR(180) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uq_user_email (email),
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB;
-- Ajout d'une colonne : ALTER généré automatiquement
ALTER TABLE user ADD phone VARCHAR(20) DEFAULT NULL;
Chaque migration a une méthode up() (applique)
et down() (annule).
Déployée automatiquement en CI avant chaque mise en production.
Le DQL ressemble au SQL mais opère sur les entités PHP et leurs associations, pas sur les tables et colonnes. Doctrine le traduit en SQL natif au moment de l'exécution. Il est utilisé dans les Repositories quand le QueryBuilder devient trop verbeux.
// DQL dans un Repository
$dql = 'SELECT a, c
FROM App\Entity\Article a
LEFT JOIN a.category c
WHERE a.status = :status
AND a.publishedAt > :since
ORDER BY a.publishedAt DESC';
// App\Entity\Article = classe PHP, pas la table SQL
// a.category = association Doctrine, pas category_id DQL plutôt que QueryBuilder quand la requête est fixe et complexe (sous-sélections, CASE WHEN).
QueryBuilder plutôt que DQL quand la requête est dynamique (filtres variables, pagination conditionnelle).
ASSOCIATIONS ENTRE ENTITÉS
3 types de relations Doctrine
La clé étrangère, sa direction et les règles de persistance varient selon le type de relation. Le choix se fait dès la modélisation, avant d'écrire la moindre ligne de code.
One-to-One
- → La FK est UNIQUE : une seule ligne de
profilepeut pointer vers un mêmeuser. - → L'entité dépendante (Profile) porte la FK. C'est le côté propriétaire (
inversedBy). - →
cascade: ['remove']côté inverse supprime le Profile quand le User est supprimé.
One-to-Many
- → La FK est côté Many (Article).
ManyToOne= côté propriétaire que Doctrine met à jour. - →
$this->articles = new ArrayCollection();dans le constructeur est obligatoire côté One. - ⚠ Appeler
getCategory()dans une boucle sans fetch JOIN : N requêtes supplémentaires.
Many-to-Many
_tag
- → Doctrine génère la table de jointure. Nommer avec
#[ORM\JoinTable]pour des migrations prévisibles. - → Seul le côté propriétaire (qui définit
@JoinTable) persiste la relation. - → Si la jointure porte des attributs (date, statut), créer une entité intermédiaire avec deux
ManyToOne.
HISTORIQUE
Versions de Doctrine ORM
Doctrine ORM suit un cycle de versions majeures aligné sur l'ecosystème Symfony. La version 3.x est la reference depuis 2024 ; la branche 2.x est en maintenance de sécurité uniquement. Voir le listing officiel des versions →
-
Doctrine ORM 3.x
RecommandéeFévr. 2024
Version majeure avec rupture de compatibilité : les annotations PHP sont supprimées (uniquement les PHP Attributes), PHP 8.1+ est requis, les proxies lazy sont entièrement refactorisés. Sur Symfony 7.x, c'est la version installée par défaut. Notre guide de migration Doctrine 2 vers 3 →
- PHP Attributes uniquement
Les annotations (@ORM\Entity, @ORM\Column) ne sont plus supportées. La migration vers les attributs PHP (#[ORM\Entity], #[ORM\Column]) est obligatoire. Le bundle MakerBundle génère directement en attributs depuis Symfony 6. - Lazy loading refactorisé
Les proxies Doctrine 3 utilisent des Ghosted Objects au lieu des classes enfants générées. Le comportement est identique mais l'initialisation est plus robuste et ne nécessite plus de répertoire de cache dédié pour les proxies. - Nettoyage de l'API
Plusieurs méthodes dépréciées dans Doctrine 2.x ont été supprimées : EntityManager::merge(), EntityManager::detach(), LifecycleEventArgs... La migration implique un audit de tous les event subscribers et repositories.
- PHP Attributes uniquement
-
Doctrine ORM 2.20.x
Support sécuritéOct. 2024
Dernière version de la branche 2.x. Ne reçoit plus que des correctifs de sécurité critiques, aucune nouvelle fonctionnalité. Compatible Symfony 6.4 LTS et PHP 8.0+. La migration vers 3.x est documentée et recommandée pour tout projet actif.
-
Doctrine ORM < 2.19
Obsolète depuis oct. 2024Mars 2024
Versions sans aucun correctif de sécurité. Les failles identifiées depuis 2022 ne sont pas corrigées sur ces branches. Tout projet encore sur ces versions doit migrer vers Doctrine 2.19.x au minimum, idéalement directement vers 3.x.
RÉFÉRENCE TECHNIQUE
Relations, index, DQL et performance
Documentation de référence pour l'équipe SmartBooster : schémas des associations Doctrine, déclaration des index, DQL et pièges classiques.
Relations entre entités
Relations entre entités
Doctrine modélise les relations entre tables SQL comme des associations entre objets PHP. Choisir le bon type de relation est la première décision d'architecture dans un projet Symfony.
One-to-One : une entité associée à exactement une autre
Exemple typique : un User a exactement un Profile. La clé étrangère (user_id) est portée par la table profile.
erDiagram
User ||--|| Profile : "a un"
User {
int id
string email
}
Profile {
int id
string bio
int user_id FK
}
// Profile.php — côté propriétaire (porte la FK)
#[ORM\OneToOne(targetEntity: User::class, inversedBy: 'profile')]
#[ORM\JoinColumn(nullable: false)]
private User $user;
// User.php — côté inverse
#[ORM\OneToOne(targetEntity: Profile::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
One-to-Many / Many-to-One : la relation la plus fréquente
Exemple : une Category contient plusieurs Article. La clé étrangère (category_id) est dans la table article.
erDiagram
Category ||--o{ Article : "contient"
Category {
int id
string name
}
Article {
int id
string title
int category_id FK
}
// Article.php — côté Many (porte la FK, côté propriétaire)
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private Category $category;
// Category.php — côté One (côté inverse, pas de FK)
#[ORM\OneToMany(targetEntity: Article::class, mappedBy: 'category', cascade: ['persist'])]
private Collection $articles;
Règle clé : la clé étrangère est toujours du côté ManyToOne. C'est aussi le côté propriétaire : Doctrine ne persiste la relation qu'en modifiant l'entité propriétaire.
Many-to-Many : table de jointure automatique
Exemple : un Article peut avoir plusieurs Tag, et un Tag peut être associé à plusieurs Article. Doctrine crée automatiquement la table de jointure article_tag.
erDiagram
Article }o--o{ Tag : "tagué avec"
Article {
int id
string title
}
article_tag {
int article_id FK
int tag_id FK
}
Tag {
int id
string label
}
// Article.php — côté propriétaire (définit la table de jointure)
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[ORM\JoinTable(name: 'article_tag')]
private Collection $tags;
// Tag.php — côté inverse
#[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
private Collection $articles;
Many-to-Many avec attributs : entité intermédiaire
Dès qu'on veut stocker des données sur la liaison elle-même (date d'association, ordre, statut, quantité...), la ManyToMany ne suffit plus. Il faut remplacer la table de jointure automatique par une entité intermédiaire avec deux ManyToOne.
Exemple : un User peut être membre de plusieurs Group, avec une date d'adhésion et un rôle dans le groupe.
erDiagram
User ||--o{ Membership : "est membre via"
Group ||--o{ Membership : "regroupe via"
User {
int id
string email
}
Membership {
int id
int user_id FK
int group_id FK
date joined_at
string role
}
Group {
int id
string name
}
// Membership.php — entité intermédiaire
#[ORM\Entity]
class Membership
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'memberships')]
#[ORM\JoinColumn(nullable: false)]
private User $user;
#[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'memberships')]
#[ORM\JoinColumn(nullable: false)]
private Group $group;
#[ORM\Column(type: 'date')]
private \DateTimeImmutable $joinedAt;
#[ORM\Column(length: 20)]
private string $role = 'member';
}
// User.php — côté inverse
#[ORM\OneToMany(targetEntity: Membership::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $memberships;
// Group.php — côté inverse
#[ORM\OneToMany(targetEntity: Membership::class, mappedBy: 'group', cascade: ['persist', 'remove'])]
private Collection $memberships;
Pour créer une adhésion :
$membership = new Membership();
$membership->setUser($user);
$membership->setGroup($group);
$membership->setJoinedAt(new \DateTimeImmutable());
$membership->setRole('admin');
$em->persist($membership);
$em->flush();
Contrairement à une ManyToMany classique où Doctrine gère la jointure en ajoutant l'entité dans la collection, ici on crée et persiste explicitement l'entité intermédiaire.
Lazy loading et N+1
Par défaut, Doctrine charge les relations en lazy loading : la requête SQL n'est exécutée qu'au premier accès à la propriété. Sur une liste, cela génère le problème N+1.
// Problème : 1 requête pour les articles + N requêtes pour les catégories
$articles = $repo->findAll();
foreach ($articles as $article) {
echo $article->getCategory()->getName(); // déclenche une requête par itération
}
// Solution : fetch JOIN dans le QueryBuilder
$articles = $repo->createQueryBuilder('a')
->leftJoin('a.category', 'c')
->addSelect('c') // charge la category dans la même requête
->getQuery()
->getResult();
Déclaration des index
Déclaration des index
Les index Doctrine sont déclarés sur la classe entité via des attributs PHP. Doctrine les intègre dans les migrations générées et les synchronise avec le schéma MySQL.
#[ORM\Entity]
#[ORM\Table(name: 'user')]
#[ORM\Index(columns: ['email'], name: 'idx_user_email')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_status_created_at')]
#[ORM\UniqueConstraint(columns: ['email'], name: 'uq_user_email')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 180, unique: true)]
private string $email;
#[ORM\Column(length: 20)]
private string $status;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
}
Index composite : l'ordre des colonnes dans columns: [...] est important. MySQL utilise l'index si la requête filtre sur le préfixe gauche des colonnes. ['status', 'created_at'] sert les requêtes WHERE status = ?, WHERE status = ? AND created_at > ?, mais pas WHERE created_at > ? seul.
Après modification : toujours régénérer la migration et vérifier le SQL généré avant de l'appliquer.
php bin/console doctrine:migrations:diff # génère le fichier de migration
php bin/console doctrine:migrations:migrate # applique en base
DQL : exemples et cas d'usage
DQL : exemples et cas d'usage
// Requête fixe et complexe : préférer DQL au QueryBuilder
$dql = 'SELECT a, c
FROM App\Entity\Article a
LEFT JOIN a.category c
WHERE a.status = :status
AND a.publishedAt > :since
ORDER BY a.publishedAt DESC';
$articles = $this->getEntityManager()
->createQuery($dql)
->setParameter('status', 'published')
->setParameter('since', new \DateTimeImmutable('-30 days'))
->setMaxResults(20)
->getResult();
FROM App\Entity\Article a cible la classe PHP, pas la table SQL. LEFT JOIN a.category c suit l'association Doctrine, pas la colonne category_id. Si l'entité est renommée, la requête DQL doit être mise à jour.
// Requête dynamique (filtres variables) : préférer QueryBuilder
$qb = $this->createQueryBuilder('a')
->leftJoin('a.category', 'c')
->addSelect('c');
if ($status) {
$qb->andWhere('a.status = :status')->setParameter('status', $status);
}
if ($since) {
$qb->andWhere('a.publishedAt > :since')->setParameter('since', $since);
}
return $qb->orderBy('a.publishedAt', 'DESC')
->setMaxResults(20)
->getQuery()
->getResult();
Jointures : join, leftJoin et rightJoin
Jointures : join, leftJoin et rightJoin
En DQL, une jointure suit une association entre entités (ex : a.category), pas une colonne SQL. Le type de jointure choisi
décide du sort des lignes de l'entité de gauche dont l'association est absente : sont-elles conservées ou écartées du résultat ?
join / innerJoin : INNER JOIN
join() est un alias de innerJoin(). La jointure ne conserve que les lignes ayant une association des deux côtés. Un Article
sans Category est exclu du résultat.
$articles = $repo->createQueryBuilder('a')
->innerJoin('a.category', 'c')
->getQuery()
->getResult();
// Seuls les articles rattachés à une catégorie sont renvoyés
leftJoin : LEFT OUTER JOIN
La jointure conserve toutes les lignes de gauche, même sans association. Un Article sans Category apparaît quand même,
son association valant null (ou une collection vide pour une relation to-many).
$articles = $repo->createQueryBuilder('a')
->leftJoin('a.category', 'c')
->getQuery()
->getResult();
// Tous les articles, catégorie à null si elle est absente
rightJoin : non supporté par Doctrine
Doctrine n'expose aucune méthode rightJoin() sur le QueryBuilder, et DQL ne connaît que INNER et LEFT [OUTER]. Pour obtenir
l'équivalent d'un RIGHT JOIN, on inverse le sens de la requête : on part de l'entité de droite et on fait un leftJoin vers la gauche.
// Pas de rightJoin : on part de Category pour garder toutes les catégories
$categories = $em->createQueryBuilder()
->select('c')
->from(Category::class, 'c')
->leftJoin('c.articles', 'a')
->getQuery()
->getResult();
// Toutes les catégories, y compris celles sans aucun article
Règle clé : le type de jointure décide uniquement des lignes de gauche sans association : INNER les écarte, LEFT les
conserve. RIGHT n'existe pas en DQL, on l'exprime avec un LEFT en changeant l'entité de départ.
Bonnes pratiques de performance
Bonnes pratiques de performance
Fetch JOIN pour éviter le N+1
Toujours charger les relations utilisées dans une boucle avec addSelect().
->leftJoin('article.tags', 'tag')
->addSelect('tag')
Cette règle est spécifique si aucun des champs de tag n'est déjà présent dans le select du QueryBuilder de la requête en question (mais qu'on y accéde en PHP par la suite via l'entité dans une boucle par exemple).
Si un des champs fait déjà partie de l'association fait déjà partie du select dans ce cas la regular join est automatiquement convertit fetch join (pas besoin d'ajouter le addSelect et plus de problème N + 1).
Exemple :
Requête Regular join sur l'adresse :
$query = $em->createQuery("SELECT u FROM User u JOIN u.address a WHERE a.city = 'Berlin'");
$users = $query->getResult();
Requête Fetch join sur l'adresse :
$query = $em->createQuery("SELECT u, a FROM User u JOIN u.address a WHERE a.city = 'Berlin'");
$users = $query->getResult();
getArrayResult() pour les listes en lecture seule
->getResult() hydrate des objets PHP complets. ->getArrayResult() retourne des tableaux associatifs, deux à trois fois plus rapides à hydrater pour les listes d'affichage sans modification.
$rows = $query->getArrayResult(); // tableau PHP, pas d'objets Entity
setMaxResults() systématiquement sur les listes
Sans pagination, une requête findAll() charge toute la table en mémoire. Toujours limiter.
->setMaxResults(50)
->setFirstResult($offset)
clear() dans les batchs d'import
Lors d'un import de masse, l'EntityManager accumule toutes les entités en mémoire (Unit of Work). Vider périodiquement évite l'explosion mémoire.
foreach ($rows as $i => $row) {
$em->persist(new Article($row));
if ($i % 200 === 0) {
$em->flush();
$em->clear(); // libère la mémoire
}
}
$em->flush();
Éviter persist() en cascade non contrôlé
cascade: ['persist'] sur une OneToMany peut déclencher des INSERT inattendus si on ajoute un item à la collection sans intention de le persister immédiatement. Préférer persist() explicite sur les entités enfants dans les cas complexes.
Pour aller plus loin
Documentation utile
Référence complète : mapping, associations, migrations, DQL et performance.
Les breaking changes et la procédure de migration détaillée pour les projets Symfony existants.
Pour aller plus loin
Approfondir votre réflexion
Doctrine ORM est le composant de persistance par défaut de Symfony. Toutes nos applications Symfony l'utilisent comme couche d'accès aux données.
Doctrine mappe les entités PHP sur MySQL via InnoDB. Les migrations, index et clés étrangères générés par Doctrine s'appuient sur les fonctionnalités transactionnelles d'InnoDB.
Doctrine ORM est au coeur de nos logiciels métier sur mesure : il garantit que la structure de la base de données reste synchronisée avec le code tout au long de l'évolution du produit.
Vous avez un projet ?
Contactez-nous pour savoir comment nous pouvons vous aider.