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.

3.x Version actuelle Sortie 2024 - PHP 8.1+ requis, breaking changes vs 2.x
2.20 Support limité - Fin de vie prévue février 2027 Correctifs de sécurité uniquement - migration vers 3.x recommandée
< 2.19 EOL Branches 2.x antérieures non maintenues - aucun correctif de sécurité

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.

ENTITÉ Modèle de données

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.

REPOSITORY Accès aux données

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.

DDL Data Definition Language

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.

DQL Doctrine Query Language

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.

1:1

One-to-One

User
profile_id FK UNIQUE
Profile
  • La FK est UNIQUE : une seule ligne de profile peut pointer vers un même user.
  • 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é.
Ex : User → Profile, Commande → Facture, Contrat → Document
1:N

One-to-Many

Category
category_id FK
Article
Article
Article
  • 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.
Ex : Category → Articles, User → Commandes, Projet → Tâches
N:M

Many-to-Many

Article
Article
article
_tag
Tag
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.
Ex : Article ↔ Tags, User ↔ Roles, Produit ↔ Catégories

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ée

    Fé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.
  • 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. 2024

    Mars 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

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

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

// 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

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

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

Documentation officielle Doctrine ORM 3

Référence complète : mapping, associations, migrations, DQL et performance.

Notre guide : migration Doctrine 2 vers 3

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

Symfony

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.

MySQL

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.

Développement de logiciel sur mesure

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.