Doctrine inheritance

L'héritage en base de données

Rémi JANOT
| r.remove.this.janot@gmail.com

1.

Quel est le problème à résoudre ?

2.

Fonctionnement

3.

Retour d'expérience

Qui suis-je ?

10100

AFOL

Comédien

Actuellement

Co-founder & CTO @ Vasco.fund

Héritage ≠ Polymorphisme

Héritage Doctrine Polymorphisme Eloquent
Pattern Table Inheritance Polymorphic Associations
But Mapper une hiérarchie de classes Une "FK" qui pointe vers N classes

1.

Quel est le problème à résoudre ?

Le besoin

Admin

  • email
  • password
  • role
  • permissions

Customer

  • email
  • password
  • address
  • loyaltyPoints

Le besoin

User

  • email
  • password

Admin

  • email
  • password
  • role
  • permissions

Customer

  • email
  • password
  • address
  • loyaltyPoints

Comment modéliser ça en base de données ?

Mapped Superclass


#[MappedSuperclass]
abstract class User
{
    #[Id]
    #[GeneratedValue]
    #[Column]
    private ?int $id = null;

    #[Column(length: 255)]
    private string $email;

    #[Column(length: 255)]
    private string $password;
}
							

#[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


#[DiscriminatorColumn(name: 'type', type: 'string')]
#[DiscriminatorMap([
    'admin'    => Admin::class,
    'customer' => Customer::class,
])]
					
idemailtype...
1alice@example.comadmin...
2bob@example.comcustomer...

Single Table Inheritance

(STI)

STI - Code PHP


#[MappedSuperclass]
abstract class User
{
    #[Id]
    #[GeneratedValue]
    #[Column]
    private ?int $id = null;

    #[Column(length: 255)]
    private string $email;

    #[Column(length: 255)]
    private string $password;
}
							

#[Entity]
class Admin extends User
{
    #[Column(length: 50)]
    private string $role;
}
							

#[Entity]
class Customer extends User
{
    #[Column(type: 'text')]
    private string $address;
}
							

STI - Code PHP


#[Entity]
#[DiscriminatorColumn(name: 'type', type: 'string')]
#[DiscriminatorMap([
    'admin'    => Admin::class,
    'customer' => Customer::class,
])]
#[InheritanceType('SINGLE_TABLE')]
abstract class User
{
    #[Id]
    #[GeneratedValue]
    #[Column]
    private ?int $id = null;

    #[Column(length: 255)]
    private string $email;

    #[Column(length: 255)]
    private string $password;
}
							

STI - SQL généré


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
);
					
idtypeemailpassword rolepermissionsaddressloyalty_points
1adminalice@acme.com$2y$... SUPER_ADMIN["MANAGE_USERS"] NULLNULL
2customerbob@mail.com$2y$... NULLNULL 12 rue de Paris150

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
					

STI - Bilan

Avantages

  • Relations vers User possibles
  • Simple à mettre en place
  • Performances (pas de JOIN)

Inconvénients

  • Table qui grossit avec les sous-types
  • Colonnes avec NULL
  • Pas de NOT NULL sur colonnes enfant
  • Schéma moins lisible

Class Table Inheritance

(CTI)

CTI - Code PHP


#[Entity]
#[InheritanceType('JOINED')]
#[DiscriminatorColumn(name: 'type', type: 'string')]
#[DiscriminatorMap([
    'admin'    => Admin::class,
    'customer' => Customer::class,
])]
abstract class User
{
    #[Id, GeneratedValue]
    #[Column]
    private ?int $id = null;

    #[Column(length: 255)]
    private string $email;

    #[Column(length: 255)]
    private string $password;
}
					

Admin et Customer : identiques au STI

CTI - SQL généré


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

idtypeemailpassword
1adminalice@acme.com$2y$...
2customerbob@mail.com$2y$...

admin

idrolepermissions
1SUPER_ADMIN["MANAGE_USERS"]

customer

idaddressloyalty_points
212 rue de Paris150

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)
  • Utiliser un Enum pour le discriminant
    
    enum UserType: string {
        case Admin = 'admin';
        case Customer = 'customer';
    }
    
    #[DiscriminatorMap([
        UserType::Admin->value    => Admin::class,
        UserType::Customer->value => Customer::class,
    ])]
    							
    Pas de magic strings
  • 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)

  1. User → Admin / Customer
    STI / CTI — selon le nombre de champs spécifiques
  2. Content → Article / Page / BlogPost
    STI — même structure (titre, slug, body), affichage différent
  3. Payment → CardPayment / BankTransfer / Crypto
    CTI — chaque moyen de paiement a ses propres champs
  4. Media → Image / Video / Document
    CTI — métadonnées très différentes
  5. Product → PhysicalProduct / DigitalProduct
    CTI — poids/dimensions vs lien de téléchargement

Cas d'usage concrets (2/2)

  1. Event → Conference / Workshop / Meetup
    STI — propriétés communes majoritaires
  2. BaseDocument → Invoice / Quote / CreditNote
    MappedSuperclass — champs communs (n°, date, client), tables séparées
  3. Discount → PercentDiscount / FixedDiscount / FreeShipping
    STI / CTI — logique de calcul différente, peu de champs
  4. Vehicle → Car / Truck / Motorcycle
    CTI — caractéristiques techniques distinctes
  5. AuditLog → LoginLog / PaymentLog / ErrorLog
    STI — table unique pour requêter tout l'historique

Pour aller plus loin

Hasard du calendrier, Kevin Bond est en train d'ajouter une série de cours sur l'héritage Doctrine sur SymfonyCasts :

symfonycasts.com/screencast/doctrine-inheritance

Côté coulisses

Cette présentation est née de problématiques rencontrées chez Vasco, où l'on utilise Doctrine au quotidien sur des sujets fintech.

On agrandit l'équipe, venez voir nos offres :

Jobs at Vasco

Merci