# Héritage Doctrine : la troisième porte que personne ne montre

> Doctrine présente 3 stratégies d'héritage ORM : STI, CTI et Mapped Superclass. Découvrez cette 3e option souvent oubliée pour vos entités complexes.

URL canonique : https://lecodeestdanslepre.fr/billets/heritage-doctrine-la-troisieme-porte-que-personne-ne-montre/
Auteur : Pierre
Langue : fr-FR
Publication : 2026-04-29T11:18:17Z
Modification : 2026-09-18T09:29:49Z

Le débat héritage Doctrine se rejoue tous les six mois : *Single Table Inheritance* ou *Class Table Inheritance *? À chaque fois, le même piège : on présente le choix comme binaire, et on oublie qu'il existe une troisième porte.

Le site que vous parcourez tourne sur une hiérarchie de contenus : `Post`, `Page`, `Category` partagent un titre, un slug, un statut, un bloc SEO embarqué, un tableau de blocs JSON pour le corps. Quand j'ai dessiné ce modèle, la question s'est posée comme dans les manuels : table unique avec colonne de discriminant, ou trois tables jointes par *Primary Key* ? J'ai failli choisir *STI*. J'ai bien fait de ne pas le faire.

La métaphore que je trouve juste, c'est celle de l'**immeuble en colocation**. *STI*, c'est un open space géant : tout le monde dort dans la même pièce, on ajoute des cloisons en carton avec une étiquette « ceci est la chambre de Post ». *CTI*, c'est un duplex avec escalier obligatoire : à chaque visite, vous montez et descendez pour récupérer vos affaires éparpillées sur deux étages. Et la troisième porte, *Mapped Superclass*, c'est trois studios indépendants qui partagent un plan d'architecte. Mêmes pièces, mêmes prises de courant, même hauteur sous plafond, mais trois bâtiments, trois compteurs, trois adresses postales.

## **Le piège du débat binaire**

Beaucoup d'articles vous présentent quasi systématiquement deux stratégies d'héritage. *Single Table Inheritance* pose une seule table physique avec une colonne `dtype` (ou `type`) qui dit qui est qui. *Class Table Inheritance* pose une table par classe et une PK partagée, ce qui implique un JOIN à chaque requête. La discussion s'enferme là, on pèse JOINS contre colonnes nullables, et on tranche.

Sauf que Doctrine offre une troisième stratégie, et ce n'est même pas une obscurité du framework : `#[ORM\MappedSuperclass]` est documentée, supportée, et fait exactement ce que son nom dit. La classe parente n'est pas une entité Doctrine. Elle existe en PHP, avec ses propriétés mappées, ses property hooks et ses méthodes, mais Doctrine ne la traite pas comme une cible de requête. Chaque enfant déclare `#[ORM\Entity]`, hérite des colonnes en PHP, et se voit attribuer **sa propre table physique, complète, autonome**.

C'est ni *STI *(un seul plancher pour tout le monde) ni *CTI* (deux planchers à recoller). C'est trois studios identiques par leur plan, mais autonomes par leurs murs.

## **Mon cas concret : **AbstractContent

Voici la classe parente, dépouillée pour l'exemple :

```php
#[ORM\MappedSuperclass]
#[ORM\HasLifecycleCallbacks]
abstract class AbstractContent implements \Stringable, SeoContentInterface
{
    use \App\Entity\Trait\TimestampableTrait;

    #[ORM\Column(type: 'uuid', unique: true)]
    #[ORM\Id]
    public ?Uuid $id;

    #[ORM\Column(type: Types::STRING, enumType: ContentStatusEnum::class)]
    public ContentStatusEnum $status = ContentStatusEnum::DRAFT;

    #[ORM\Embedded(class: SeoMetadata::class, columnPrefix: 'seo_')]
    public SeoMetadata $seo;

    #[ORM\Column(length: 255)]
    public string $title = '';

    #[ORM\Column(length: 255, unique: true)]
    public ?string $slug = null;

    #[ORM\Column(type: Types::JSON, nullable: true)]
    public ?array $blocks = null;

    // ... + property hooks pour listingSummary, plainTextContent, etc.
}
```

Trois enfants. `Post` ajoute une catégorie, un niveau de difficulté, un éventuel repo GitHub. `Page` ajoute une `referenceKey` qui sert à identifier les pages techniques (mentions légales, contact). `Category` ajoute une collection de `Post`. Chacun a son `#[ORM\Entity]`, ses index, sa région de cache de second niveau :

```php
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE', region: 'post_cache')]
#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\Index(name: 'idx_post_category_status', columns: ['category_id', 'status'])]
class Post extends AbstractContent
{
    #[ORM\ManyToOne(inversedBy: 'posts')]
    public ?Category $category = null;

    #[ORM\Column(type: Types::STRING, enumType: ProficiencyLevelEnum::class)]
    public ProficiencyLevelEnum $proficiencyLevel = ProficiencyLevelEnum::INTERMEDIATE;
}
```

Résultat physique en base : trois tables, `post`, `page`, `category`, chacune avec sa colonne `id`, ses colonnes communes (`title`, `slug`, `status`, `seo_*`, `blocks`), et ses colonnes spécifiques. Aucune colonne `dtype`. Aucun JOIN. Trois immeubles autonomes avec le même plan d'architecte.

## **Pourquoi STI aurait été un mauvais choix**

Reprenons l'open space. Si je passe en *STI*, j'obtiens une table unique `content` avec :

- une colonne `dtype` qui vaut `post`, `page` ou `category`,
- toutes les colonnes spécifiques à chaque type, **toutes nullables**, parce qu'une `Page` n'a pas de `category_id` et un `Post` n'a pas de `reference_key`,
- une seule série d'index, partagés entre des cas qui n'ont rien à voir.

Trois problèmes opérationnels apparaissent immédiatement.

D'abord, la relation `Post.category_id → Category.id` devient une **auto-référence sur la même table**. Doctrine s'en sort, mais le SQL devient pénible à lire et les contraintes d'intégrité moins lisibles. Quand on relit un schema, l'*auto-FK* sur une table polymorphique est la première source de confusion.

Ensuite, et c'est le point qui m'a fait reculer en mars dernier : ce blog utilise la **recherche plein-texte PostgreSQL**. Chaque table porte sa propre colonne `tsvector` mise à jour par trigger. La pondération n'est pas la même pour un `Post` (le titre pèse lourd, le contenu beaucoup, la `category` un peu) et pour une `Page` (le titre pèse, le contenu pèse, point final). Avec STI, j'aurais eu une seule colonne `search_vector` partagée. Soit je pondère identiquement et la pertinence s'effondre, soit je conditionne la pondération dans le trigger en testant `dtype` et je transforme une requête FTS propre en arbre `CASE` illisible.

Enfin, mes index ciblés (`idx_post_category_status`, `idx_page_reference_key`) n'ont aucun sens transversal. Sur une table unique, ils deviennent des index conditionnels (`WHERE dtype = 'post'`), possibles en PostgreSQL, mais qui ajoutent une couche de complexité gratuite quand trois tables séparées résolvent le problème sans rien dire à personne.

*STI *a un domaine d'élection : la hiérarchie polymorphique pure. Vehicle / Car / Truck, où le code applicatif demande souvent « tous les véhicules » sans distinction. Ce n'est pas mon cas. Je ne demande jamais « tous les contenus du blog » dans une même requête. Je demande des `Post` publiés, des `Page` actives, des `Category` non vides. Trois requêtes, trois tables, trois plans d'exécution propres.

## **Pourquoi CTI aurait été lourd pour rien**

Le duplex. *CTI* poserait `content` (avec les colonnes communes) **plus** `post`, `page`, `category` (avec les colonnes spécifiques). Chaque lecture d'un `Post` impliquerait un JOIN entre `content` et `post`. À chaque écriture, deux INSERT. À chaque suppression, deux DELETE en cascade.

Le coût des JOINs n'est pas catastrophique sur des tables petites avec des PK indexées, on parle de dizaines de microsecondes. Mais c'est un coût **gratuit** dans mon cas. Je paye un JOIN pour quoi ? Pour pouvoir, théoriquement, écrire `SELECT c FROM AbstractContent c WHERE c.publishedAt > ...` et récupérer indistinctement des `Post`, des `Page` et des `Category` modifiés cette semaine. Or je ne fais jamais cette requête. Mes flux RSS, mon sitemap, mon dashboard admin : tout est typé. Pas de cas d'usage polymorphique en lecture.

*CTI *vaut le coup quand vous avez une vraie collection polymorphique, par exemple un système de commentaires qui peut pointer vers n'importe quel contenu via une relation `ManyToOne` vers le parent. Là, vous avez besoin que `AbstractContent` soit une vraie table avec une vraie PK pour que la FK ait du sens. Sans ce besoin, CTI vous fait payer chaque jour pour une option que vous n'exercez jamais.

## **Mapped Superclass : ce qu'on gagne, ce qu'on perd**

**Ce qu'on gagne**, point par point sur mon cas :

- **Trois tables propres**, avec leurs colonnes attendues, leurs FK explicites, leurs contraintes uniques cohérentes.
- **Index ciblés** : `idx_post_category_status` n'existe que sur `post` parce que c'est la seule table qui a un `category_id`.
- **Triggers FTS spécifiques** : chaque table a son propre `tsvector` avec sa pondération adaptée, et l'unaccent + pg\_trgm fonctionnent par table.
- **Cache de second niveau par région** : `post_cache`, `page_cache`, `category_cache`. Invalider une catégorie n'invalide pas les pages.
- **Code DRY côté PHP** : property hooks, méthodes utilitaires (`blocksToPlainText`, `generateExcerpt`), Embeddable SEO, tout est mutualisé dans `AbstractContent`.

**Ce qu'on perd**, et il faut le savoir avant de signer :

- **Pas de DQL sur le parent**. Écrire `SELECT c FROM App\Entity\Content\AbstractContent c` lève une exception. Doctrine ne sait pas mapper le parent, c'est par construction.
- **Pas de relation polymorphique**. Aucune entité ne peut faire un `ManyToOne` vers `AbstractContent`. Si demain je veux un système de commentaires unifié sur `Post` et `Page`, je dois soit dupliquer la relation (`commentsOnPost`, `commentsOnPage`), soit basculer en CTI.
- **Pas de PK partagée**. Un UUID de `Post` peut techniquement entrer en collision avec un UUID de `Page` (probabilité infinitésimale, mais le contrat n'est pas garanti par le schéma). Ce n'est jamais un problème en pratique avec UUID v4, mais c'est à savoir.

C'est un contrat clair : **Mapped Superclass dit : « ces classes partagent du code, pas une identité de domaine. »** Si vos classes partagent une identité de domaine, si « tous les véhicules » a un sens métier, alors c'est *CTI* ou STI selon les patterns d'accès. Si elles partagent juste du code, *Mapped Superclass* est plus honnête.

## **Quand basculer ailleurs**

Trois signaux disent qu'il est temps de quitter *Mapped Superclass*.

**Premier signal** : vous écrivez la même boucle sur deux repositories pour reconstituer un flux unifié. Si vous faites `$posts = ...; $pages = ...; $merged = array_merge($posts, $pages); usort($merged, ...);` plus de deux fois dans l'application, c'est que le besoin polymorphique est réel. Bascule en CTI.

**Deuxième signal** : une entité externe veut référencer n'importe lequel de vos types. Comments, Likes, Reactions, AuditLog. Si la cible naturelle est « le contenu », pas « le post précis », c'est* CTI*.

**Troisième signal** : les colonnes spécifiques deviennent rares et marginales. Si `Post` n'a plus que deux colonnes spécifiques sur trente, et que les trois types sont sémantiquement très proches, *STI* peut redevenir compétitif, surtout si vous voulez un seul index full-text pour tout.

Aucun de ces signaux ne s'est manifesté ici en un an. J'écris encore avec* Mapped Superclass* et je ne le regrette pas.

## **Le mot de la fin**

L'erreur c'est de présenter *STI *et *CTI* comme les deux seules options sérieuses. Elles le sont quand vous modélisez une vraie hiérarchie de domaine. Elles sont sur-dimensionnées, au sens littéral puisqu'elles ajoutent des dimensions inutiles à votre schéma, quand vous voulez juste mutualiser du code PHP entre des entités qui restent indépendantes en base.

*Mapped Superclass* n'est pas un compromis. C'est une stratégie distincte avec son propre contrat : pas d'identité partagée, pas de polymorphisme, mais du code DRY au-dessus de tables propres.

Le test simple, avant de choisir : posez-vous la question *« est-ce que je veux un jour requêter la classe parente ? »*. Si la réponse est non, ne payez pas pour cette option. *Mapped Superclass* vous laisse trois clés, trois studios, trois compteurs. Et le jour où vraiment, vraiment, vous voulez un duplex, Doctrine vous laisse migrer : ce sera douloureux, mais c'est rarissime.
