#[Entity]
class Admin extends User
{
#[Column(length: 50)]
private string $role;
}
#[Entity]
class Customer extends User
{
#[Column(type: 'text')]
private string $address;
}
Mapped Superclass - SQL
-- Pas de table "user" !
CREATE TABLE admin (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
role VARCHAR NOT NULL
permissions JSON NOT NULL
);
CREATE TABLE customer (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
address TEXT NOT NULL
loyalty_points INT NOT NULL
);
Avantages : partage de code, simple, pas de NULL
Limites : pas de requête "tous les users" pas de relation vers User, User n'est pas une entité,
On veut plus !
Requêter tous les Users d'un coup
Avoir des relations vers l'entité parente
Garder l'héritage en PHP
Il nous faut un vrai héritage en BDD
2.
Fonctionnement
Le Discriminant
La colonne qui dit à Doctrine quelle classe PHP instancier
CREATE TABLE user (
id INT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(255) NOT NULL, -- discriminant
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(50), -- admin, NULL si customer
permissions JSON, -- admin, NULL si customer
address TEXT, -- customer, NULL si admin
loyalty_points INT -- customer, NULL si admin
);
id
type
email
password
role
permissions
address
loyalty_points
1
admin
alice@acme.com
$2y$...
SUPER_ADMIN
["MANAGE_USERS"]
NULL
NULL
2
customer
bob@mail.com
$2y$...
NULL
NULL
12 rue de Paris
150
STI - Requêtes
// Tous les utilisateurs (Admin + Customer mélangés)
$users = $em->getRepository(User::class)->findAll();
SELECT * FROM user;
-- Doctrine instancie Admin ou Customer selon la colonne "type"
// Seulement les Admin
$admins = $em->getRepository(Admin::class)->findAll();
SELECT * FROM user WHERE type = 'admin';
// Fonctionne aussi en DQL
$query = $em->createQuery(
'SELECT u FROM App\Entity\User u WHERE u.email LIKE :domain'
);
// Retourne des Admin ET des Customer
CREATE TABLE user (
id INT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);
CREATE TABLE admin (
id INT PRIMARY KEY,
role VARCHAR(50) NOT NULL, -- NOT NULL possible !
permissions JSON NOT NULL,
FOREIGN KEY (id) REFERENCES user(id)
);
CREATE TABLE customer (
id INT PRIMARY KEY,
address TEXT NOT NULL, -- NOT NULL possible !
loyalty_points INT NOT NULL,
FOREIGN KEY (id) REFERENCES user(id)
);
CTI - Données
user
id
type
email
password
1
admin
alice@acme.com
$2y$...
2
customer
bob@mail.com
$2y$...
admin
id
role
permissions
1
SUPER_ADMIN
["MANAGE_USERS"]
customer
id
address
loyalty_points
2
12 rue de Paris
150
CTI - Requêtes
// Tous les utilisateurs
$users = $em->getRepository(User::class)->findAll();
SELECT u.id, u.type, u.email, u.password,
a.role, a.permissions,
c.address, c.loyalty_points
FROM user u
LEFT JOIN admin a ON u.id = a.id
LEFT JOIN customer c ON u.id = c.id;
// Seulement les Admin
$admins = $em->getRepository(Admin::class)->findAll();
SELECT u.id, u.email, u.password,
a.role, a.permissions
FROM user u
INNER JOIN admin a ON u.id = a.id;
CTI - Bilan
Avantages
Schéma normalisé
Pas de colonnes avec NULL
NOT NULL possible sur les enfants
Propre et lisible
Inconvénients
JOINs systématiques
Performances moindres sur gros volumes
INSERT = plusieurs tables
Plus complexe à débugger
DQL - INSTANCE OF
// Tous les Admin parmi les Users
$query = $em->createQuery(
'SELECT u FROM App\Entity\User u
WHERE u INSTANCE OF App\Entity\Admin'
);
// Tous les Users qui ne sont PAS des Admin
$query = $em->createQuery(
'SELECT u FROM App\Entity\User u
WHERE u NOT INSTANCE OF App\Entity\Admin'
);
// Combiné avec d'autres conditions
$query = $em->createQuery(
'SELECT u FROM App\Entity\User u
WHERE u INSTANCE OF App\Entity\Customer
AND u.loyaltyPoints > :minPoints'
)->setParameter('minPoints', 100);
DQL - INSTANCE OF
// Dashboard en une seule requête
$query = $em->createQuery(
'SELECT NEW App\DTO\UserStats(
COUNT(u),
SUM(CASE WHEN u INSTANCE OF App\Entity\Admin THEN 1 ELSE 0 END),
SUM(CASE WHEN u INSTANCE OF App\Entity\Customer THEN 1 ELSE 0 END),
AVG(c.loyaltyPoints)
)
FROM App\Entity\User u
LEFT JOIN App\Entity\Customer c WITH c.id = u.id'
);
// → UserStats { total: 150, admins: 12, customers: 138, avgLoyalty: 847.5 }
Relations
AuditLog → User
#[Entity]
class AuditLog
{
#[ManyToOne(
targetEntity: User::class
)]
private User $user;
#[Column]
private string $action;
}
Order → Customer
#[Entity]
class Order
{
#[ManyToOne(
targetEntity: Customer::class
)]
private Customer $customer;
#[Column]
private float $total;
}
Une relation vers la classe parente accepte toutes les sous-classes → AuditLog.user peut être un Admin ou un Customer
Comparatif
Mapped Superclass
STI
CTI
Requête globale
Non
Oui
Oui
Relations vers parent
Non
Oui
Oui
Schéma normalisé
Oui
Non
Oui
NOT NULL enfants
Oui
Non
Oui
Performance lecture
N/A
Excellente
JOINs
Performance écriture
N/A
1 INSERT
2 INSERT ou +
Colonnes NULL
Aucune
Beaucoup
Aucune
3.
Retour d'expérience
Single Table Inheritance
Table de plus en plus large
Colonnes NULL qui s'accumulent
Migrer de STI vers CTI
-- 1. Créer les tables enfant
CREATE TABLE admin (
id INT PRIMARY KEY REFERENCES user(id),
role VARCHAR(50) NOT NULL,
permissions JSON NOT NULL
);
CREATE TABLE customer (
id INT PRIMARY KEY REFERENCES user(id),
address TEXT,
loyalty_points INT NOT NULL DEFAULT 0
);
-- 2. Migrer les données
INSERT INTO admin (id, role, permissions)
SELECT id, role, permissions FROM user WHERE type = 'admin';
INSERT INTO customer (id, address, loyalty_points)
SELECT id, address, loyalty_points FROM user WHERE type = 'customer';
Migrer de STI vers CTI
-- 3. Nettoyer la table parente
ALTER TABLE user
DROP COLUMN role,
DROP COLUMN permissions,
DROP COLUMN address,
DROP COLUMN loyalty_points;
// 4. Mettre à jour les attributs Doctrine
-#[InheritanceType('SINGLE_TABLE')]
+#[InheritanceType('JOINED')]
⚠ Toujours tester sur un dump de production avant !
CTI - JOINs coûteux
Avec beaucoup de sous-classes ou de données, les LEFT JOINs ne sont plus si anodins
Le problème du "downcast"
Un Admin (id: 1) devient un Customer ?
// Ceci ne fonctionne PAS avec Doctrine
$customer = $em->find(Customer::class, 1);
// Ceci non plus
$user = $em->find(User::class, 1);
// Impossible de "transformer" un Admin en Customer via Doctrine
Doctrine ne supporte pas nativement le changement de type
Le discriminant est figé à la création de l'entité
Le problème du "downcast"
Solutions possibles :
Requête SQL native pour :
modifier le discriminant
supprimer l'ancienne ligne enfant
insérer la nouvelle ligne enfant
Créer une nouvelle entité et supprimer l'ancienne
Repenser le modèle (composition plutôt qu'héritage)
INSTANCE OF et QueryBuilder
⚠ Ne pas utiliser setParameter() avec INSTANCE OF
// NE PAS FAIRE : setParameter avec INSTANCE OF
$qb->andWhere('u INSTANCE OF :type')
->setParameter('type', Admin::class);
// Pas d'erreur explicite, mais résultats incorrects !
// Interpoler directement le FQCN dans le DQL
$qb->andWhere(
sprintf('u INSTANCE OF %s', Admin::class)
);
// Ou passer le ClassMetadata via setParameter
$qb->andWhere('u INSTANCE OF :type')
->setParameter('type', $em->getClassMetadata(Admin::class));
Bonnes pratiques
Toujours définir le DiscriminatorMap explicitement Ne pas laisser Doctrine le deviner (fragile, noms de classes en BDD)
Garder la hiérarchie simple - 1 seul niveau d'héritage
Indexer la colonne discriminant / id - Accélère les requêtes filtrées
Tester les migrations avant de changer de stratégie
Choisir sa stratégie
Pas besoin de requête globale ? → Mapped Superclass - Simple partage de mapping
Peu de propriétés spécifiques par sous-type ? → Single Table Inheritance - Performance maximale
Beaucoup de propriétés / besoin de NOT NULL / schéma propre ? → Class Table Inheritance - Normalisation
Hiérarchie complexe avec beaucoup de comportements différents ? → Repenser le modèle - Peut-être que la composition est plus adaptée
Composition vs Héritage
Un Admin qui redevient Customer ? Peut-être que le modèle est faux.
Héritage
class User { ... }
class Admin extends User {
private string $role;
private array $permissions;
}
class Customer extends User {
private int $loyaltyPoints;
}
Le type est figé à vie
Composition
class User {
#[OneToOne]
private ?AdminProfile $admin;
#[OneToOne]
private ?CustomerProfile $customer;
}
Un User peut évoluer librement
Cas d'usage concrets (1/2)
User → Admin / Customer STI / CTI — selon le nombre de champs spécifiques
Content → Article / Page / BlogPost STI — même structure (titre, slug, body), affichage différent
Payment → CardPayment / BankTransfer / Crypto CTI — chaque moyen de paiement a ses propres champs
Media → Image / Video / Document CTI — métadonnées très différentes
Product → PhysicalProduct / DigitalProduct CTI — poids/dimensions vs lien de téléchargement
Cas d'usage concrets (2/2)
Event → Conference / Workshop / Meetup STI — propriétés communes majoritaires