Home BlogHéritage avec Doctrine : quelle stratégie choisir ?

Héritage avec Doctrine : quelle stratégie choisir ?

Mapped Superclass · Single Table · Class Table · … ou pas d'héritage du tout

15 min 27 octobre 2026

Dès qu’une application a plusieurs types d’utilisateurs, la question revient : comment ranger une hiérarchie de classes PHP dans une base relationnelle ? Doctrine propose trois stratégies d’héritage, et il existe une quatrième option qu’on oublie souvent : ne pas utiliser l’héritage.

Dans cet article, on prend un exemple concret, on le code avec chaque approche, on regarde le schéma et le SQL générés, puis on voit dans quels cas chacune a du sens.

Le cas d’école : nos utilisateurs

On a une application avec trois types d’utilisateurs :

  • tous ont un email, un password et une date de création ;
  • Member a des points de fidélité et une date de fin d’abonnement ;
  • Moderator modère des catégories de forum ;
  • Admin a une liste de permissions et un niveau.

Côté PHP, la hiérarchie se dessine toute seule :

User (abstract)
├── id, email, password, createdAt
│
├─▶ Member
│     loyaltyPoints, subscriptionEndsAt
├─▶ Moderator
│     moderatedCategories
└─▶ Admin
      permissions, level

Pour stocker ça, on a quatre options :

  1. Mapped Superclass : on partage du code et du mapping, avec une table par classe concrète. Pas d’héritage côté ORM.
  2. Single Table Inheritance (SINGLE_TABLE) : toute la hiérarchie dans une seule table, avec une colonne qui indique le type.
  3. Class Table Inheritance (JOINED) : une table par classe, reliées par jointure sur la clé primaire.
  4. Composition : un seul User, avec des rôles et/ou des profils associés.

1. Mapped Superclass

C’est l’option « je veux juste ne pas dupliquer mon code ».

La classe parente

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\MappedSuperclass]
abstract class User
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    protected ?int $id = null;

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

    #[ORM\Column]
    protected string $password;

    #[ORM\Column]
    protected \DateTimeImmutable $createdAt;

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

    abstract public function getRoles(): array;
}

User n’est pas une entité : elle n’a ni table, ni repository. Elle sert uniquement à transmettre ses propriétés et leur mapping aux classes enfants.

Les entités concrètes

#[ORM\Entity(repositoryClass: MemberRepository::class)]
#[ORM\Table(name: 'member')]
class Member extends User
{
    #[ORM\Column]
    private int $loyaltyPoints = 0;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $subscriptionEndsAt = null;

    public function getRoles(): array { return ['ROLE_MEMBER']; }
}

#[ORM\Entity(repositoryClass: ModeratorRepository::class)]
#[ORM\Table(name: 'moderator')]
class Moderator extends User
{
    #[ORM\ManyToMany(targetEntity: ForumCategory::class)]
    private Collection $moderatedCategories;

    public function __construct(string $email)
    {
        parent::__construct($email);
        $this->moderatedCategories = new ArrayCollection();
    }

    public function getRoles(): array { return ['ROLE_MODERATOR']; }
}

#[ORM\Entity(repositoryClass: AdminRepository::class)]
#[ORM\Table(name: 'admin')]
class Admin extends User
{
    #[ORM\Column(type: 'json')]
    private array $permissions = [];

    #[ORM\Column]
    private int $level = 1;

    public function getRoles(): array { return ['ROLE_ADMIN']; }
}

Le schéma généré

Chaque classe concrète a sa propre table, avec les colonnes communes recopiées :

member moderator admin
id (PK) id (PK) id (PK)
email email email
password password password
created_at created_at created_at
loyalty_points permissions
subscription_ends_at level

La table moderator n’a pas de colonne propre : les catégories modérées sont stockées dans une table de jointure moderator_forum_category.

Les colonnes communes sont dupliquées, et il n’existe aucun lien entre les tables.

Ce qu’on ne peut pas faire

Comme User n’est pas une entité, Doctrine ne sait rien faire avec elle :

// User n'est pas une entité, donc pas de repository
$em->getRepository(User::class)->findAll(); // MappingException

// Pas de DQL polymorphe non plus
$em->createQuery('SELECT u FROM App\Entity\User u'); // erreur

// Impossible de cibler "un User quelconque" dans une relation
class Article
{
    #[ORM\ManyToOne(targetEntity: User::class)] // Member ou Admin ?
    private User $author;
}

// Pas de OneToMany sur la mapped superclass
// (les relations héritées doivent être unidirectionnelles)

Autre point à garder en tête : l’unicité n’est pas garantie entre les tables. member.email et admin.email peuvent contenir la même adresse, et Member#1 n’a rien à voir avec Admin#1.

Bilan

Avantages

  • C’est l’option la plus simple, sans aucune magie.
  • Pas de jointure, pas de colonne inutile.
  • Les contraintes NOT NULL fonctionnent normalement.
  • On peut surcharger le mapping hérité avec #[AttributeOverride] et #[AssociationOverride].

Inconvénients

  • Aucune requête polymorphe.
  • Aucune relation possible vers le type parent.
  • Schéma dupliqué : une modification des champs communs, c’est N tables à migrer.
  • Pas d’unicité transverse (sur l’email par exemple).

En résumé, la Mapped Superclass sert à réutiliser du code, pas à modéliser un héritage métier.

2. Single Table Inheritance

Ici, toute la hiérarchie tient dans une seule table.

La racine de la hiérarchie

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'user')]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string', length: 20)]
#[ORM\DiscriminatorMap([
    'member'    => Member::class,
    'moderator' => Moderator::class,
    'admin'     => Admin::class,
])]
abstract class User
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    protected ?int $id = null;

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

    #[ORM\Column]
    protected string $password;

    // ...
}

Trois attributs font le travail :

  • InheritanceType('SINGLE_TABLE') choisit la stratégie ;
  • DiscriminatorColumn déclare la colonne qui stocke le type ;
  • DiscriminatorMap associe chaque valeur de cette colonne à une classe.

La colonne type est gérée par Doctrine. Il n’y a pas de propriété PHP correspondante, et on n’a pas à la remplir.

Les sous-classes

#[ORM\Entity]
class Member extends User
{
    #[ORM\Column(nullable: true)] // nullable obligatoire
    private ?int $loyaltyPoints = 0;
}

#[ORM\Entity]
class Moderator extends User
{
    #[ORM\ManyToMany(targetEntity: ForumCategory::class)]
    private Collection $moderatedCategories;
}

#[ORM\Entity]
class Admin extends User
{
    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $permissions = [];

    #[ORM\Column(nullable: true)]
    private ?int $level = 1;
}

Pas de #[ORM\Table] sur les enfants : tout va dans la table user.

Toutes les colonnes propres à une sous-classe doivent être nullable. C’est logique : une ligne admin n’a pas de loyalty_points, donc la base doit accepter NULL dans cette colonne.

Le schéma généré

Une seule table :

user
├── id (PK)
├── email UNIQUE
├── password
├── created_at
├── type                 ('member' | 'moderator' | 'admin')
├── loyalty_points       NULL
├── subscription_ends_at NULL
├── permissions          NULL
└── level                NULL

Et voici à quoi ressemblent les données :

id email type loyalty_points permissions level
1 [email protected] member 120 NULL NULL
2 [email protected] moderator NULL NULL NULL
3 [email protected] admin NULL [« USER_EDIT »] 3

On voit vite les NULL s’accumuler.

Requêter la hiérarchie

En contrepartie, le polymorphisme fonctionne :

// Polymorphe : renvoie des Member, Moderator et Admin mélangés
$users = $em->getRepository(User::class)->findAll();
// SELECT ... FROM user

// Filtré automatiquement sur le discriminant
$admins = $em->getRepository(Admin::class)->findAll();
// SELECT ... FROM user WHERE type IN ('admin')

// En DQL, avec INSTANCE OF
$staff = $em->createQuery(
    'SELECT u FROM App\Entity\User u
     WHERE u INSTANCE OF App\Entity\Admin
        OR u INSTANCE OF App\Entity\Moderator'
)->getResult();

Et une relation peut cibler la racine de la hiérarchie :

#[ORM\ManyToOne(targetEntity: User::class)]
private User $author;

Bilan

Avantages

  • C’est la stratégie la plus performante : jamais de jointure.
  • Requêtes et relations polymorphes.
  • Unicité transverse sur l’email et l’id.
  • Ajouter une sous-classe revient à ajouter quelques colonnes.
  • Changer le type d’une ligne se fait avec un UPDATE sur le discriminant.

Inconvénients

  • Les colonnes spécifiques sont forcément nullable : l’intégrité des données doit être assurée côté PHP (avec le Validator par exemple).
  • La table devient un fourre-tout qui grossit avec la hiérarchie.
  • Beaucoup de NULL si les sous-classes ont peu de champs en commun.

3. Class Table Inheritance

Cette fois on veut un schéma normalisé, quitte à payer des jointures.

Le mapping

Par rapport à Single Table, le changement principal tient en une ligne : JOINED à la place de SINGLE_TABLE. On ajoute aussi un #[ORM\Table] sur chaque enfant.

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'user')]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string', length: 20)]
#[ORM\DiscriminatorMap([
    'member' => Member::class, 'moderator' => Moderator::class, 'admin' => Admin::class,
])]
abstract class User { /* id, email, password, createdAt */ }

#[ORM\Entity]
#[ORM\Table(name: 'member')]
class Member extends User
{
    #[ORM\Column] // NOT NULL possible ici
    private int $loyaltyPoints = 0;
}

#[ORM\Entity]
#[ORM\Table(name: 'admin')]
class Admin extends User
{
    #[ORM\Column(type: 'json')]
    private array $permissions = [];

    #[ORM\Column]
    private int $level = 1;
}

Chaque sous-classe ayant sa propre table, ses colonnes peuvent de nouveau être NOT NULL.

Le schéma généré

user                     member
├── id (PK)   ◀───────── ├── id (PK, FK → user.id)
├── email UNIQUE         ├── loyalty_points
├── password             └── subscription_ends_at
├── created_at
└── type                 moderator
                ◀─────── └── id (PK, FK → user.id)

                         admin
                ◀─────── ├── id (PK, FK → user.id)
                         ├── permissions
                         └── level

La table user contient les champs communs et le discriminant. Chaque table enfant a une clé primaire qui est aussi une clé étrangère vers user.id. Le SchemaTool et les migrations génèrent cette FK avec ON DELETE CASCADE.

Le SQL généré

C’est là qu’on voit le coût de cette stratégie :

-- $em->getRepository(Admin::class)->find(3)
SELECT u.*, a.permissions, a.level
FROM admin a
INNER JOIN user u ON a.id = u.id WHERE a.id = 3;

-- $em->getRepository(User::class)->findAll()  (polymorphe)
SELECT u.*, m.loyalty_points, m.subscription_ends_at, a.permissions, a.level
FROM user u
LEFT JOIN member    m  ON u.id = m.id
LEFT JOIN moderator mo ON u.id = mo.id
LEFT JOIN admin     a  ON u.id = a.id;

Une requête sur la racine fait un LEFT JOIN par sous-classe. Plus la hiérarchie est large, plus la requête est lourde. Côté écriture, un INSERT se traduit par deux requêtes : une dans la table parente, une dans la table enfant.

Bilan

Avantages

  • Schéma normalisé, sans colonnes vides.
  • Contraintes NOT NULL, clés étrangères et index propres à chaque sous-type.
  • Requêtes et relations polymorphes.
  • Unicité transverse sur l’email et l’id.
  • Fonctionne bien quand les sous-classes sont très différentes.

Inconvénients

  • Des jointures à chaque lecture et plusieurs requêtes à chaque écriture.
  • Les performances se dégradent avec la profondeur et le nombre de sous-classes.
  • Changer de type impose de supprimer une ligne dans une table enfant et d’en insérer une dans une autre.

Les pièges communs à Single Table et Class Table

Les relations vers la racine sont chargées immédiatement

class Article
{
    #[ORM\ManyToOne(targetEntity: User::class)] // User a des sous-classes
    private User $author;
}

$article = $articleRepo->find(42);
// Doctrine ne sait pas quelle classe de proxy créer (Member ? Admin ?)
// → il charge l'auteur immédiatement : 1 requête de plus par article (N+1)

Quand la cible d’une relation est la racine d’une hiérarchie, Doctrine n’a que l’id en main. Il ne sait pas quel proxy instancier, donc il va chercher l’auteur tout de suite. Sur une liste de 50 articles, ça fait 50 requêtes supplémentaires.

Pour l’éviter : ciblez si possible une classe feuille (Admin::class), ou chargez la relation avec un JOIN FETCH en DQL.

« Promouvoir un Member en Admin »

// Impossible en PHP : un objet ne change pas de classe
$member->becomeAdmin(); // ???

// STI : passer par DBAL puis vider l'UnitOfWork
$em->getConnection()->update('user', ['type' => 'admin'], ['id' => $id]);
$em->clear();
// CTI : en plus, DELETE FROM member + INSERT INTO admin ...

En PHP, un objet ne change pas de classe. Pour transformer un Member en Admin, il faut contourner l’ORM : un UPDATE du discriminant via DBAL en Single Table, et en plus un DELETE et un INSERT dans les tables enfants en Class Table.

Si un rôle peut évoluer dans le temps, c’est généralement que l’héritage n’est pas le bon outil : le rôle décrit alors l’état de l’utilisateur à un instant donné.

Bonnes pratiques

  • Déclarez toujours la DiscriminatorMap explicitement.
  • Rendez la racine abstract si elle ne doit jamais être instanciée.
  • En Single Table, validez les champs « obligatoires » avec #[Assert\NotNull], puisque la base ne le fera pas.
  • Filtrez par type en DQL avec INSTANCE OF plutôt qu’en lisant la colonne discriminante.
  • Gardez une hiérarchie plate et courte : un ou deux niveaux.
  • Les stratégies se combinent : on peut placer une MappedSuperclass au-dessus d’une racine Single Table ou Class Table.

4. L’alternative : la composition

Le vieux principe « favor composition over inheritance » s’applique bien ici. Dans beaucoup d’applications, un utilisateur possède un ou plusieurs rôles plutôt qu’il n’appartient à un sous-type.

Variante A : des rôles simples, à la Symfony

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface
{
    #[ORM\Column(type: 'json')]
    private array $roles = []; // ['ROLE_MEMBER', 'ROLE_ADMIN', ...]

    public function getRoles(): array
    {
        return array_unique([...$this->roles, 'ROLE_USER']);
    }

    public function promote(string $role): void
    {
        $this->roles[] = $role;
    }
}

C’est la bonne approche quand les rôles ne portent que des droits, sans données spécifiques. On la combine avec le role_hierarchy de Symfony et des Voters.

Variante B : des profils associés

Quand un rôle a ses propres données, on les place dans une entité dédiée reliée à User :

#[ORM\Entity]
class User
{
    #[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
    private ?MemberProfile $memberProfile = null;

    #[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
    private ?AdminProfile $adminProfile = null;

    public function isAdmin(): bool { return $this->adminProfile !== null; }
}

#[ORM\Entity]
class AdminProfile
{
    #[ORM\Id, ORM\OneToOne(inversedBy: 'adminProfile')]
    private User $user;

    #[ORM\Column(type: 'json')]
    private array $permissions = [];

    #[ORM\Column]
    private int $level = 1;
}

Un utilisateur peut être Member et Moderator en même temps, et promouvoir quelqu’un revient à lui créer un profil.

Réutiliser du mapping sans hériter : Traits et Embeddables

Pour mutualiser des champs sans passer par une MappedSuperclass, deux outils plus légers existent.

Un trait :

trait TimestampableTrait
{
    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt;
}

class User { use TimestampableTrait; }

Un embeddable :

#[ORM\Embeddable]
class Address
{
    #[ORM\Column] private string $street;
    #[ORM\Column] private string $city;
}

class User
{
    #[ORM\Embedded(class: Address::class)]
    private Address $address;
}

Bilan

Avantages

  • Changer ou cumuler des rôles devient trivial.
  • Un seul repository, une seule identité.
  • Les relations vers User peuvent être chargées en lazy.
  • Le schéma reste propre et contraint (dans les profils).
  • Ça colle au modèle de sécurité de Symfony.

Inconvénients

  • Plus de surcharge de méthode par type : la logique propre à chaque rôle doit vivre ailleurs.
  • Sans discipline, on finit avec des if ($user->isAdmin()) partout. Mieux vaut centraliser cette logique dans des Voters ou des services.
  • La base ne garantit pas la cohérence : rien n’empêche un utilisateur d’avoir ROLE_ADMIN sans AdminProfile.
  • Le côté inverse d’un OneToOne est toujours chargé immédiatement : chaque User déclenche une requête par profil (50 utilisateurs, 50 requêtes en plus). Là encore, un JOIN FETCH règle le problème.

Récapitulatif

Mapped Superclass Single Table Class Table (Joined) Composition
Nombre de tables 1 par classe concrète 1 1 + 1 par sous-classe 1 (+ profils)
Requêtes polymorphes Non Oui Oui n/a
Relation vers le parent Non Oui (eager) Oui (eager) Oui (lazy)
Performance en lecture Bonne Bonne Moyenne à faible Bonne / moyenne
Contraintes NOT NULL Oui Non Oui Oui
Colonnes vides Aucune Beaucoup Aucune Aucune
Unicité transverse (email) Non Oui Oui Oui
Changer de rôle Migration de ligne UPDATE DBAL DELETE + INSERT Simple
Cumuler des rôles Non Non Non Oui
Complexité Faible Faible Moyenne Faible

Quand choisir quoi ?

Mapped Superclass : pour des entités sans lien métier qui partagent seulement des colonnes techniques (id, createdAt…), et qu’on ne requêtera jamais ensemble. Par exemple une BaseEntity ou un AbstractLog.

Single Table : pour des sous-types proches, avec peu de champs spécifiques, beaucoup de lectures polymorphes et un besoin de performance. Par exemple une Notification (Email / Sms / Push) ou un Payment.

Class Table (Joined) : pour des sous-types très différents, avec beaucoup de champs obligatoires, un besoin d’intégrité forte et une volumétrie raisonnable. Par exemple un Contract (Particulier / Entreprise) ou un Vehicle.

Composition : quand le type évolue dans le temps ou se cumule, ce qui est le cas typique des rôles utilisateurs. Par exemple un User avec ses roles et un AdminProfile.

Et si vous hésitez, cet arbre de décision couvre la plupart des cas :

Le « type » peut-il changer ou se cumuler ?
├── Oui ──▶ Composition (rôles / profils)
└── Non ── Doit-on requêter / relier le type parent ?
        ├── Non ──▶ Mapped Superclass (ou Trait)
        └── Oui ── Sous-classes proches, peu de champs propres ?
                 ├── Oui ──▶ Single Table
                 └── Non ──▶ Class Table (Joined)

À retenir

  • MappedSuperclass : partage de code, sans polymorphisme.
  • SINGLE_TABLE : rapide et simple, mais des colonnes nullable.
  • JOINED : schéma propre et contraint, mais des jointures.
  • Pour des rôles utilisateurs qui évoluent, la composition est presque toujours le meilleur choix.
  • L’héritage convient à une relation « est un » qui ne change pas au cours de la vie de l’objet.

Pour aller plus loin : la documentation officielle de Doctrine sur l’Inheritance Mapping.

Lire les articles similaires

Laisser un commentaire