Files
abonnel-www/cca462ab-fe40-4155-a0ed-b4f00a40ad2a/index.md
T

14 KiB

Doctrine ORM : reprendre le contrôle de sa couche de données en PHP moderne

Pendant longtemps, écrire du PHP, c'était écrire du SQL à la main. On ouvrait une connexion, on construisait une requête à coups de concaténations, on échappait (ou pas) les paramètres, on bouclait sur le résultat, et on transformait laborieusement chaque ligne en quelque chose qui ressemblait à un objet. Multiplié par cent tables, ça faisait des milliers de lignes de plomberie répétitive — et autant d'occasions de glisser une injection SQL ou un bug subtil.

Les générateurs de code des années 2000 (POG, Propel première version, et tant d'autres) ont tenté de répondre au problème en automatisant la génération des classes d'accès aux données. C'était une bonne idée pour l'époque. Aujourd'hui, l'approche dominante est différente : ce n'est plus du code généré, c'est un ORM — un Object-Relational Mapper — qui fait le pont entre tes objets PHP et ta base de données, à la volée, sans étape de génération.

En PHP, le standard de fait pour cette approche est Doctrine ORM. C'est lui qu'on va explorer dans cet article.

C'est quoi un ORM, au fond ?

Un ORM, c'est une bibliothèque qui te permet d'écrire ceci :

$user = $entityManager->find(User::class, 42);
echo $user->getName();

...au lieu de ceci :

$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$user = new User();
$user->setId($row['id']);
$user->setName($row['name']);
// ... et ainsi de suite pour chaque colonne
echo $user->getName();

L'ORM s'occupe de :

  • traduire tes opérations sur des objets en requêtes SQL,
  • hydrater les résultats SQL en objets PHP propres,
  • gérer les relations entre objets (un utilisateur a plusieurs commandes, une commande appartient à un utilisateur),
  • garder en mémoire l'état de tes objets pour détecter automatiquement ce qui a changé,
  • t'épargner 90% du SQL répétitif.

Le bénéfice est double. Côté productivité, tu écris du code métier qui ressemble à du code métier, pas à de la tuyauterie. Côté sécurité, l'ORM utilise systématiquement des requêtes préparées : les injections SQL deviennent un mauvais souvenir.

Active Record vs Data Mapper : deux philosophies

Avant d'entrer dans Doctrine, il faut comprendre une distinction fondamentale entre deux familles d'ORM, parce qu'elle change tout dans la façon d'écrire ton code.

L'Active Record met l'objet et sa persistance dans la même classe. L'objet sait se sauvegarder lui-même :

$user = new User();
$user->name = 'Alice';
$user->save();  // l'objet User connaît la base

C'est l'approche d'Eloquent (Laravel), de CakePHP, ou des vieux générateurs comme POG. C'est intuitif, rapide à apprendre, parfait pour des applications simples.

Le Data Mapper sépare l'objet métier de sa persistance. L'entité ne sait rien de la base ; c'est un service externe (l'EntityManager) qui orchestre les sauvegardes :

$user = new User('Alice');
$entityManager->persist($user);
$entityManager->flush();

C'est l'approche de Doctrine, et c'est le pattern recommandé par Martin Fowler dans son ouvrage de référence sur les architectures applicatives. C'est plus verbeux, mais ça paye dès que ton modèle métier devient complexe : tes entités restent pures, testables sans base de données, et tu peux raisonner sur ton domaine sans te soucier de la persistance.

Bref : Active Record pour la vitesse de mise en route, Data Mapper pour la rigueur architecturale à long terme.

Doctrine en pratique

Place au code. On va construire un mini-modèle pour un blog : User qui possède plusieurs Post, chaque Post ayant plusieurs Comment.

Installation

composer require doctrine/orm doctrine/dbal
composer require doctrine/migrations  # pour gérer le schéma

Une seule commande, et tu disposes d'un ORM industriel testé par des milliers de projets en production.

Définir une entité

Avec PHP 8 et les attributes, la déclaration d'une entité est devenue particulièrement élégante :

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 100)]
    private string $name;

    #[ORM\Column(type: 'string', length: 180, unique: true)]
    private string $email;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author', cascade: ['persist'])]
    private Collection $posts;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = new \DateTimeImmutable();
        $this->posts = new ArrayCollection();
    }

    public function getId(): ?int { return $this->id; }
    public function getName(): string { return $this->name; }
    public function getEmail(): string { return $this->email; }
    public function getPosts(): Collection { return $this->posts; }

    public function addPost(Post $post): void
    {
        if (!$this->posts->contains($post)) {
            $this->posts->add($post);
            $post->setAuthor($this);
        }
    }
}

Ce qu'il faut retenir : la classe User est un pur objet PHP. Aucun héritage d'une classe magique, aucune méthode save(). Les #[ORM\...] ne sont que des annotations qui décrivent comment Doctrine doit traduire l'objet vers la base. L'entité reste testable seule, sans qu'aucune base ne tourne.

Une entité reliée

#[ORM\Entity]
#[ORM\Table(name: 'posts')]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 200)]
    private string $title;

    #[ORM\Column(type: 'text')]
    private string $content;

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'posts')]
    #[ORM\JoinColumn(nullable: false)]
    private User $author;

    public function __construct(string $title, string $content, User $author)
    {
        $this->title = $title;
        $this->content = $content;
        $this->author = $author;
    }

    public function setAuthor(User $author): void { $this->author = $author; }
    public function getAuthor(): User { return $this->author; }
    public function getTitle(): string { return $this->title; }
}

La relation ManyToOne côté Post et OneToMany côté User forment ensemble une association bidirectionnelle : tu peux naviguer dans les deux sens, et Doctrine s'occupe de la cohérence.

Configurer l'EntityManager

Un fichier bootstrap.php à la racine :

<?php

use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\EntityManager;
use Doctrine\DBAL\DriverManager;

require_once __DIR__ . '/vendor/autoload.php';

$config = ORMSetup::createAttributeMetadataConfiguration(
    paths: [__DIR__ . '/src/Entity'],
    isDevMode: true,
);

$connection = DriverManager::getConnection([
    'driver'   => 'pdo_mysql',
    'host'     => 'localhost',
    'dbname'   => 'mon_blog',
    'user'     => 'root',
    'password' => 'secret',
    'charset'  => 'utf8mb4',
], $config);

$entityManager = new EntityManager($connection, $config);

C'est tout. L'$entityManager est ton point d'entrée pour toutes les opérations de persistance.

Créer et sauvegarder

$alice = new User('Alice', 'alice@example.com');
$post = new Post('Mon premier article', 'Le contenu...', $alice);

$entityManager->persist($alice);
$entityManager->persist($post);
$entityManager->flush();

Deux concepts clés ici :

  • persist() dit à Doctrine : "à partir de maintenant, surveille cet objet, il fait partie du contexte de persistance".
  • flush() exécute réellement les INSERT/UPDATE/DELETE nécessaires, dans une transaction.

Tu peux persist() cinquante objets et faire un seul flush() à la fin. Doctrine optimise les requêtes, gère l'ordre d'insertion en fonction des clés étrangères, et roule tout dans une transaction. Ce découplage est ce qui rend Doctrine si puissant pour la logique métier complexe.

Lire

// Par ID
$user = $entityManager->find(User::class, 1);

// Par critère
$user = $entityManager->getRepository(User::class)
    ->findOneBy(['email' => 'alice@example.com']);

// Tous
$users = $entityManager->getRepository(User::class)->findAll();

Modifier

Là, c'est presque magique :

$user = $entityManager->find(User::class, 1);
$user->setName('Alice Dupont');
$entityManager->flush();

Pas de persist(), pas de update() explicite. Doctrine compare l'état actuel de l'objet à l'état chargé depuis la base et calcule automatiquement la requête UPDATE minimale nécessaire. C'est ce qu'on appelle le Unit of Work pattern.

Supprimer

$user = $entityManager->find(User::class, 1);
$entityManager->remove($user);
$entityManager->flush();

Requêter avec DQL

Pour les requêtes complexes, Doctrine fournit DQL (Doctrine Query Language) — un dialecte SQL-like qui raisonne en termes d'objets et de propriétés, pas de tables et de colonnes :

$query = $entityManager->createQuery(
    'SELECT u FROM App\Entity\User u 
     WHERE u.createdAt > :date 
     ORDER BY u.name ASC'
);
$query->setParameter('date', new \DateTimeImmutable('-30 days'));
$recentUsers = $query->getResult();

Et pour les cas où DQL devient lourd, le QueryBuilder offre une approche programmatique :

$qb = $entityManager->createQueryBuilder();
$qb->select('u')
   ->from(User::class, 'u')
   ->where('u.createdAt > :date')
   ->andWhere('u.email LIKE :pattern')
   ->setParameter('date', new \DateTimeImmutable('-30 days'))
   ->setParameter('pattern', '%@example.com')
   ->orderBy('u.name', 'ASC');

$users = $qb->getQuery()->getResult();

Tous les paramètres sont automatiquement échappés. L'injection SQL n'est plus un risque par défaut.

Les migrations : versionner ton schéma

Une fois tes entités définies, comment crée-tu les tables correspondantes ? Et comment fais-tu évoluer le schéma quand tu ajoutes une colonne, six mois plus tard, sur trois environnements différents ?

Réponse : Doctrine Migrations.

# Générer une migration à partir des changements détectés
vendor/bin/doctrine-migrations diff

# Appliquer les migrations en attente
vendor/bin/doctrine-migrations migrate

Chaque migration est un fichier PHP horodaté, versionnable dans Git. Tu peux remonter, redescendre, rejouer sur n'importe quel environnement. C'est la différence entre "j'ai un schéma" et "j'ai un schéma reproductible et auditable".

Les pièges à connaître

Doctrine est puissant, mais il a sa courbe d'apprentissage et quelques pièges classiques.

Le problème N+1. Si tu charges 100 utilisateurs et que tu accèdes à $user->getPosts() pour chacun, Doctrine peut générer 101 requêtes (1 pour les users, puis 1 par user pour ses posts). La solution : utiliser un JOIN FETCH dans tes requêtes pour précharger les relations. C'est le piège le plus fréquent et le plus coûteux en performance.

L'oubli du flush(). Tes persist() et remove() ne font rien tant que tu n'as pas flush(). Erreur de débutant fréquente : "ma sauvegarde ne marche pas". Il manque le flush().

Les entités déconnectées. Si tu sérialises une entité, la stockes en session, et la récupères au prochain requête, Doctrine ne la "connaît" plus. Il faut soit la merge() (ancienne API) soit la re-find() pour la réintégrer au contexte.

L'overkill pour les petits projets. Doctrine est un outil de gros calibre. Pour un script utilitaire ou un projet de 3 tables, du PDO bien écrit ou un micro-ORM comme Atlas.Orm peuvent suffire. Choisis ton outil en fonction de la taille du problème.

Quand choisir Doctrine ?

Doctrine est particulièrement pertinent quand :

  • ton modèle métier est riche et mérite d'être traité comme tel (logique dans les entités, invariants, value objects),
  • tu veux séparer clairement domaine et infrastructure (architecture hexagonale, DDD),
  • tu prévois une évolution longue du schéma avec plusieurs développeurs,
  • tu veux pouvoir changer de SGBD un jour (Doctrine supporte MySQL, PostgreSQL, SQLite, SQL Server, Oracle).

À l'inverse, si tu construis un CRUD simple, à courte durée de vie, ou si l'équipe préfère un style plus direct, Eloquent (utilisable hors Laravel via illuminate/database) sera probablement plus rapide à prendre en main.

Pour aller plus loin

La documentation officielle est de très bonne qualité : https://www.doctrine-project.org/projects/doctrine-orm/en/current/tutorials/getting-started.html

Le tutoriel "Getting Started" te fait construire un mini-projet de A à Z en une heure. C'est le meilleur point d'entrée pour passer de la théorie à la pratique.

À explorer ensuite, par ordre de priorité : les relations (OneToOne, ManyToMany, héritage d'entités), les événements de cycle de vie (PrePersist, PostLoad...), les types personnalisés (pour stocker des value objects de ton domaine), et le second-level cache pour les performances en production.

En résumé

Doctrine n'est pas le seul ORM PHP, et ce n'est pas toujours le bon choix. Mais quand on travaille en PHP vanilla sur un projet sérieux, qu'on veut un Data Mapper rigoureux et qu'on respecte son modèle métier, c'est l'outil de référence. Il représente plus de quinze ans d'évolution continue, une communauté solide, une intégration impeccable avec PHP 8 et ses attributes, et un écosystème complet (migrations, fixtures, extensions).

Passer d'une vieille approche manuelle (ou d'un générateur d'objets d'une autre époque) à Doctrine demande un investissement réel — il faut comprendre l'EntityManager, le Unit of Work, les associations, le DQL. Mais c'est un investissement qui rentabilise très vite : ton code devient plus court, plus sûr, plus testable, et ton modèle métier reprend la place centrale qu'il mérite.