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, unpasswordet 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 :
- Mapped Superclass : on partage du code et du mapping, avec une table par classe concrète. Pas d’héritage côté ORM.
- Single Table Inheritance (
SINGLE_TABLE) : toute la hiérarchie dans une seule table, avec une colonne qui indique le type. - Class Table Inheritance (
JOINED) : une table par classe, reliées par jointure sur la clé primaire. - 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) |
| 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 NULLfonctionnent 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 ;DiscriminatorColumndéclare la colonne qui stocke le type ;DiscriminatorMapassocie 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 | 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
UPDATEsur 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
NULLsi 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
DiscriminatorMapexplicitement. - Rendez la racine
abstractsi 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 OFplutô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
MappedSuperclassau-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
Userpeuvent ê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_ADMINsansAdminProfile. - Le côté inverse d’un
OneToOneest toujours chargé immédiatement : chaqueUserdéclenche une requête par profil (50 utilisateurs, 50 requêtes en plus). Là encore, unJOIN FETCHrè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.