OneToMany Symfony : les pièges Doctrine qu'on rencontre tous

· · 22 min de lecture

Read this article in English

Une commande et ses lignes, une catégorie et ses produits, un article et ses commentaires : la relation OneToMany est partout. make:entity la génère en quelques questions, et la notion de relation Doctrine tient en un paragraphe.

Pourtant, c'est l'une des sources de bugs que je croise le plus souvent dans les projets Symfony que j'audite. Une ligne qui ne s'enregistre pas, une ligne qui disparaît, une page qui envoie deux cents requêtes, une commande qui affiche une seule ligne sur trois. Aucun de ces bugs ne lève d'erreur claire au bon endroit.

Cet article passe en revue ces pièges sur un exemple unique, une commande et ses lignes. Chaque comportement décrit ici a été vérifié sur Doctrine ORM 3.7, requêtes SQL à l'appui. Vous pouvez le constater vous-même : à la fin de chaque section, un lien ouvre l'exemple dans le bac à sable Symfony, dans votre navigateur et sans compte, avec les requêtes envoyées et la correction à essayer.

Le point de départ

#[ORM\Entity]
class Commande
{
    /** @var Collection<int, LigneDeCommande> */
    #[ORM\OneToMany(targetEntity: LigneDeCommande::class, mappedBy: 'commande')]
    private Collection $lignes;

    public function __construct()
    {
        $this->lignes = new ArrayCollection();
    }
}

#[ORM\Entity]
class LigneDeCommande
{
    #[ORM\ManyToOne(inversedBy: 'lignes')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Commande $commande = null;

    public function __construct(
        #[ORM\Column] private string $produit,
        #[ORM\Column] private int $quantite = 1,
        #[ORM\Column] private int $position = 0,
        #[ORM\Column] private bool $offerte = false,
    ) {
    }
}

En base, il n'y a qu'une colonne : commande_id, dans la table des lignes. La relation OneToMany n'existe pas en SQL. C'est une vue de l'esprit côté PHP, et presque tous les pièges qui suivent viennent de là.

Piège n°1 : écrire du côté qui ne compte pas

Doctrine distingue deux côtés. Le côté propriétaire est la ManyToOne, LigneDeCommande::$commande, celle qui porte la colonne commande_id. Le côté inverse est la OneToMany, Commande::$lignes, déclarée avec mappedBy. Pour décider de la valeur de commande_id, Doctrine ne regarde que le côté propriétaire.

Ajouter une ligne à la seule collection ne fait donc rien d'utile :

$commande->getLignes()->add($ligne);
$entityManager->persist($ligne);
$entityManager->flush();
// NOT NULL constraint failed: ligne_de_commande.commande_id

Doctrine tente d'insérer la ligne avec une commande_id vide. Avec nullable: false, la base refuse ; sans, la ligne est enregistrée orpheline, sans aucune erreur, ce qui est pire.

Le retrait a le même défaut. Retirer une ligne de la collection avec removeElement(), sans autre configuration, n'envoie aucune requête : la ligne reste rattachée à sa commande en base.

L'inverse est trompeur aussi. Si l'on renseigne seulement le côté propriétaire, la base est juste, mais l'objet en mémoire ne l'est pas toujours :

$ligne->setCommande($commande);
$entityManager->persist($ligne);
$entityManager->flush();

// 0 si la collection était déjà chargée : Doctrine ne la met pas à jour.
count($commande->getLignes());

Un total de commande calculé juste après sur getLignes() sera faux.

La parade : ne jamais manipuler la collection de l'extérieur, mais passer par des méthodes qui tiennent les deux côtés à jour. C'est ce que génère make:entity :

public function addLigne(LigneDeCommande $ligne): static
{
    if (!$this->lignes->contains($ligne)) {
        $this->lignes->add($ligne);
        $ligne->setCommande($this);
    }

    return $this;
}

public function removeLigne(LigneDeCommande $ligne): static
{
    if ($this->lignes->removeElement($ligne)) {
        if ($ligne->getCommande() === $this) {
            $ligne->setCommande(null);
        }
    }

    return $this;
}

Attention au removeLigne() généré : il remet la commande de la ligne à null. Avec nullable: false, et sans la configuration du piège n°3, le flush() tente un UPDATE ... SET commande_id = NULL que la base refuse. C'est un signal utile : une ligne de commande sans commande n'a pas de sens, il faut décider de la supprimer.

Le getter, lui, renvoie la collection modifiable. On peut typer son retour en ReadableCollection : l'objet reste le même à l'exécution, mais PHPStan et l'IDE signaleront un getLignes()->add(). Évitez en revanche de renvoyer toArray(), qui charge toutes les lignes à chaque appel et fait perdre l'optimisation EXTRA_LAZY du piège n°5.

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

Piège n°2 : la cascade, ni partout ni nulle part

Avec addLigne(), les deux côtés sont à jour. Mais si l'on ne persiste que la commande :

$commande->addLigne(new LigneDeCommande('Café', 2));
$entityManager->persist($commande);
$entityManager->flush();

Doctrine refuse :

A new entity was found through the relationship 'Commande#lignes' that was not
configured to cascade persist operations for entity: LigneDeCommande@234.

C'est d'ailleurs en parcourant le côté inverse que Doctrine repère cette ligne inconnue : il ne s'en sert pas pour écrire commande_id, mais il le lit pour trouver les entités nouvelles.

Le message propose deux solutions : appeler persist() sur la ligne, ou ajouter cascade: ['persist'] sur la relation. Les deux sont correctes, et le choix n'est pas une affaire de goût.

La cascade a du sens quand l'enfant n'existe pas sans le parent : une ligne de commande, une adresse de livraison, une réponse de questionnaire. On parle d'agrégat : on enregistre la commande, et elle emporte ses lignes.

Elle n'en a pas quand l'enfant a sa propre vie. Une catégorie et ses produits, par exemple : on ne crée pas un produit en enregistrant une catégorie. Mettre cascade: ['persist'] partout « pour ne plus voir l'erreur » fait disparaître un garde-fou utile, celui qui signale qu'une entité nouvelle s'est glissée là où on ne l'attendait pas.

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

Piège n°3 : trois façons de supprimer, trois comportements

Supprimer une commande qui a des lignes, sans rien configurer, échoue sur la clé étrangère (sous SQLite, seulement si les clés étrangères sont activées avec PRAGMA foreign_keys = ON). Il existe trois mécanismes pour s'en occuper, souvent confondus.

cascade: ['remove'], sur la OneToMany, est géré par Doctrine. Avant de supprimer la commande, il charge les lignes et les supprime une par une. Pour une commande de trois lignes :

SELECT ... FROM ligne_de_commande t0 WHERE t0.commande_id = ?
DELETE FROM ligne_de_commande WHERE id = ?
DELETE FROM ligne_de_commande WHERE id = ?
DELETE FROM ligne_de_commande WHERE id = ?
DELETE FROM commande WHERE id = ?

C'est lent sur de gros volumes, mais les écouteurs Doctrine (preRemove, postRemove) sont appelés pour chaque ligne.

onDelete: 'CASCADE', sur la JoinColumn du côté propriétaire, est géré par la base. Seul, il fait envoyer une requête à Doctrine :

DELETE FROM commande WHERE id = ?

C'est la base qui supprime les lignes. C'est rapide, mais Doctrine n'en sait rien : aucun écouteur n'est appelé, et les objets LigneDeCommande déjà chargés restent en mémoire, gérés par l'EntityManager, alors qu'ils n'existent plus en base. Son vrai intérêt : une suppression qui ne passe pas par remove(), comme un DELETE en DQL ou en SQL, que Doctrine n'accompagne d'aucune cascade.

orphanRemoval: true, sur la OneToMany, répond à une autre question : que devient une ligne qu'on retire de la collection ? Sans cette option, rien, ou une erreur avec le removeLigne() généré. Avec, Doctrine la supprime au flush() :

$commande->removeLigne($ligne);
$entityManager->flush();
// DELETE FROM ligne_de_commande WHERE id = ?

C'est souvent ce que l'on veut pour un agrégat : une ligne retirée de la commande n'a plus de raison d'exister. Et orphanRemoval implique aussi la suppression en cascade quand la commande elle-même est supprimée, avec les mêmes requêtes que cascade: ['remove'], même si onDelete: 'CASCADE' est aussi présent.

Ma règle : cascade: ['persist'] et orphanRemoval: true pour un agrégat ; onDelete: 'CASCADE' en plus si l'on supprime aussi des commandes en masse par une requête DQL ou SQL.

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

Piège n°4 : remplacer la collection

Celui-ci est le plus coûteux, parce qu'il détruit des données. Il apparaît avec un setter, souvent ajouté pour un formulaire ou une API :

public function setLignes(array $lignes): void
{
    $this->lignes = new ArrayCollection($lignes);
}

Prenons une commande de trois lignes, avec cascade: ['persist'] et orphanRemoval: true. On lui passe ses trois lignes existantes, plus une nouvelle, déjà rattachée à la commande :

$nouvelleLigne = new LigneDeCommande('Jus');
$nouvelleLigne->setCommande($commande);

$commande->setLignes([...$commande->getLignes(), $nouvelleLigne]);
$entityManager->flush();

Le résultat attendu : quatre lignes. Les requêtes envoyées :

DELETE FROM ligne_de_commande WHERE commande_id = ?
INSERT INTO ligne_de_commande (produit, quantite, position, offerte, commande_id) VALUES (?, ?, ?, ?, ?)

Il reste une ligne. En voyant la collection d'origine remplacée par un autre objet, Doctrine considère qu'elle a été vidée : avec orphanRemoval, il supprime toutes ses lignes d'un coup, y compris celles qui figurent aussi dans la nouvelle collection, puis il insère la seule ligne nouvelle. Sans orphanRemoval, le remplacement n'efface rien, mais il ne retire rien non plus : le setter ne fait pas ce que son nom promet.

La parade : pas de setter de collection. La collection est créée une fois, dans le constructeur, puis modifiée sur place avec addLigne() et removeLigne(). Les outils de Symfony savent s'en servir : le Serializer et le PropertyAccessor utilisent l'adder et le remover quand ils existent, et un CollectionType de formulaire le fait avec l'option by_reference: false.

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

Piège n°5 : les requêtes N+1, et les collections à moitié chargées

Une collection OneToMany est chargée paresseusement : la requête part au premier parcours. Afficher une liste de cinq commandes avec leurs lignes envoie donc six requêtes, une pour la liste et une par commande. Avec cinquante commandes, cinquante et une. C'est le problème N+1.

La parade connue est la jointure. Mais une jointure seule ne suffit pas :

// Toujours six requêtes : la jointure filtre, elle n'hydrate pas les lignes.
$this->createQueryBuilder('c')
    ->leftJoin('c.lignes', 'l')
    ->getQuery()->getResult();

// Une seule requête : addSelect() demande à Doctrine de remplir les collections.
$this->createQueryBuilder('c')
    ->leftJoin('c.lignes', 'l')
    ->addSelect('l')
    ->getQuery()->getResult();

(leftJoin() plutôt que join() : un join() ferait disparaître les commandes sans ligne.)

Cette jointure avec addSelect() a deux pièges à elle.

Filtrer sur la jointure donne des collections incomplètes. On veut les commandes qui contiennent au moins une ligne offerte :

$this->createQueryBuilder('c')
    ->join('c.lignes', 'l')
    ->addSelect('l')
    ->where('l.offerte = true')
    ->getQuery()->getResult();

Les commandes sont les bonnes, mais chacune n'a plus qu'une ligne dans getLignes() : celle qui est offerte. Doctrine a rempli la collection avec ce que la requête a renvoyé, et la considère désormais comme complète. Pire, elle le reste pour toute la vie de l'EntityManager, jusqu'à un clear() : un find() ou un findOneBy() ultérieur sur la même commande renvoie l'objet déjà en mémoire, avec sa collection tronquée. Dans une commande console ou un worker Messenger, cela peut durer longtemps. J'ai vu ce bug dans une facture qui n'affichait qu'une partie des lignes, selon le code exécuté juste avant dans le même traitement.

La parade : sortir le filtre de la jointure qui hydrate, par exemple avec une sous-requête EXISTS. La requête reste unique, et les collections sont complètes :

$this->createQueryBuilder('c')
    ->leftJoin('c.lignes', 'l')
    ->addSelect('l')
    ->where('EXISTS (SELECT l2.id FROM App\Entity\LigneDeCommande l2 WHERE l2.commande = c AND l2.offerte = true)')
    ->getQuery()->getResult();

Limiter le nombre de résultats coupe les collections. setMaxResults(2) sur une requête avec jointure limite le nombre de lignes SQL, pas le nombre de commandes. On obtient une commande avec deux lignes au lieu de deux commandes complètes. Pour paginer une requête avec jointure, il faut le paginateur de Doctrine, Doctrine\ORM\Tools\Pagination\Paginator : il récupère d'abord les identifiants des commandes de la page, puis les commandes complètes, en deux requêtes (plus une pour le total).

Enfin, si une page n'a besoin que du nombre de lignes, fetch: 'EXTRA_LAZY' sur la relation évite de les charger : tant que la collection n'est pas chargée, count($commande->getLignes()) envoie un SELECT COUNT(*) au lieu de récupérer toutes les lignes. contains() et slice() en profitent aussi.

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

Piège n°6 : #[ORM\OrderBy] ne trie qu'au chargement

#[ORM\OneToMany(targetEntity: LigneDeCommande::class, mappedBy: 'commande')]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $lignes;

L'attribut ajoute un ORDER BY à la requête qui charge la collection. C'est tout. Une ligne ajoutée avec addLigne() se place à la fin, quelle que soit sa position, et y reste jusqu'au prochain chargement :

$commande->addLigne(new LigneDeCommande('Pain', 1, position: 0));
$entityManager->flush();
// En mémoire : Café, Thé, Jus, Pain
// Rechargée : Pain, Café, Thé, Jus

Si l'ordre compte juste après un ajout, par exemple pour renvoyer la commande dans la réponse de l'API, il faut trier soi-même :

use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;

$this->lignes->matching(Criteria::create()->orderBy(['position' => Order::Ascending]));

▶ Reproduire ce piège dans le bac à sable Symfony, requêtes SQL comprises.

En résumé

Situation Ce qui se passe La parade
getLignes()->add() seul Clé étrangère vide, ou ligne orpheline addLigne() qui met à jour les deux côtés
Enfant nouveau, parent persisté « A new entity was found… » persist() explicite, ou cascade: ['persist'] pour un agrégat
Ligne retirée de la collection Rien, ou erreur NOT NULL orphanRemoval: true pour un agrégat
Suppression du parent Erreur de clé étrangère orphanRemoval ou cascade: ['remove'] (Doctrine), onDelete: 'CASCADE' (base)
Collection remplacée Avec orphanRemoval, toutes les anciennes lignes supprimées Pas de setter, un adder et un remover
Boucle sur les collections N+1 requêtes leftJoin() + addSelect()
Filtre sur la jointure Collections incomplètes, gardées en mémoire Filtrer avec EXISTS
Pagination avec jointure Collections coupées Paginator de Doctrine

Un dernier conseil : lancez php bin/console doctrine:schema:validate. Il ne voit pas ces pièges de comportement, mais il signale un mappedBy ou un inversedBy qui ne se répondent pas, et c'est souvent par là que commence le piège n°1.

← Tous les articles