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ême pièces, même 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, 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 :
#[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 :
#[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
dtypequi vautpost,pageoucategory, - toutes les colonnes spécifiques à chaque type, toutes nullables, parce qu'une
Pagen'a pas decategory_idet unPostn'a pas dereference_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_statusn'existe que surpostparce que c'est la seule table qui a uncategory_id. - Triggers FTS spécifiques : chaque table a son propre
tsvectoravec 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é dansAbstractContent.
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 clève une exception. Doctrine ne sait pas mapper le parent, c'est par construction. - Pas de relation polymorphique. Aucune entité ne peut faire un
ManyToOneversAbstractContent. Si demain je veux un système de commentaires unifié surPostetPage, je dois soit dupliquer la relation (commentsOnPost,commentsOnPage), soit basculer en CTI. - Pas de PK partagée. Un UUID de
Postpeut techniquement entrer en collision avec un UUID dePage(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, 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.
Cet article vous a-t-il aidé ?
Vos réactions ne sont pas encore enregistrées — bientôt disponible.