Introduction
À l’ère de l’IA, on pourrait presque croire qu’en développement il n’est plus vraiment nécessaire de se prendre la tête avec les concepts techniques. On balance quelques prompts, on laisse tourner le vibe coding et l’application finit par fonctionner.
Sauf qu’en pratique, laisser Symfony et Doctrine partir en roue libre finit souvent par créer plus de problèmes qu’autre chose.
Parce qu’au-delà des simples findAll() et des requêtes CRUD basiques, Doctrine cache énormément d’outils puissants… mais aussi énormément de pièges. Hydratation excessive, requêtes inutiles, N+1 silencieux, mémoire consommée pour rien ou encore repositories qui deviennent incontrôlables : tout ça arrive très vite dès qu’un projet commence à grossir.
Dans ce premier article, on va donc explorer les mécanismes réellement utiles de Doctrine dans un contexte avancé. L’objectif n’est pas de faire du “Doctrine magique”, mais de comprendre quels outils utiliser, comment les utiliser intelligemment et surtout comment éviter d’overhead inutilement le framework Symfony.
L’idée est simple : reprendre le contrôle sur les performances, la lisibilité et le comportement réel de l’ORM.
ORM vs SQL : comprendre ce que Doctrine fait réellement
Quand on regarde beaucoup de conférences techniques — notamment certaines conférences de l’AFUP — un constat revient souvent : dès qu’un besoin devient réellement critique en termes de performances, on finit rarement par tout faire en PHP.
Et c’est logique.
Dans de nombreux cas, le moteur de base de données est bien plus optimisé que l’application elle-même pour manipuler de gros volumes de données. Que ce soit avec PostgreSQL, MySQL ou un autre moteur SQL, certaines opérations doivent être laissées directement à la base de données : indexation, agrégation, calculs complexes, recherche avancée ou encore optimisation des plans d’exécution.
Pourtant, dans un projet Symfony classique, on utilise presque systématiquement Doctrine ORM comme couche d’abstraction entre l’application et la base de données.
Et ce n’est pas un mauvais choix.
Doctrine apporte énormément de confort :
- mapping objet/relationnel ;
- hydratation automatique ;
- gestion des relations ;
- QueryBuilder ;
- UnitOfWork ;
- lazy loading ;
- abstraction SQL ;
- gestion du cycle de vie des entités.
Le problème, ce n’est donc pas Doctrine en lui-même.
Le problème vient surtout du fait qu’on oublie souvent ce que Doctrine fait réellement sous le capot.
Car derrière un simple :
$users = $userRepository->findAll();Doctrine ne récupère pas juste des lignes SQL.
Il hydrate des objets, initialise des métadonnées, surveille les changements d’état des entités, construit des relations et maintient tout un contexte mémoire via son UnitOfWork.
Et sur des applications importantes, ce coût devient rapidement énorme.
C’est d’ailleurs là que beaucoup de projets Symfony commencent à souffrir :
- trop d’entités hydratées inutilement ;
- trop de relations chargées ;
- trop d’objets gardés en mémoire ;
- requêtes SQL inefficaces ;
- logique métier mélangée à la récupération des données.
La réalité, c’est qu’un ORM n’est pas censé remplacer SQL.
Un ORM est un outil d’abstraction qui permet d’accélérer le développement et de structurer correctement une application.
Et lorsqu’il est bien utilisé, Doctrine ORM reste un outil extrêmement performant.
Dans la suite de cet article, on va voir trois cas concrets où Doctrine peut réellement améliorer les performances et la qualité d’un projet Symfony… à condition de comprendre comment l’utiliser intelligemment.
Embeddable : unifier une logique métier pour mieux la réutiliser
Avec Doctrine, on a souvent le réflexe de créer une entité dès qu’un concept métier apparaît dans l’application.
C’est pratique, mais ce n’est pas toujours nécessaire.
Prenons un exemple simple : un produit possède un prix. Ce prix est composé d’un montant et d’une devise. On pourrait évidemment répéter ces deux champs directement dans l’entité Product :
private int $priceAmountCents;
private string $priceCurrency;Mais très vite, cette logique peut se retrouver ailleurs : une commande, une facture, un abonnement, une transaction, voire des actions en bourse ou des conversions de devises.
Et si demain on veut gérer plusieurs monnaies, ajouter du dollar, du franc suisse, ou même intégrer une logique autour de la cryptomonnaie, on ne veut pas dupliquer cette logique dans toutes les entités.
C’est exactement là que les Embeddable Doctrine deviennent intéressants.
Un Embeddable, c’est une manière d’intégrer un objet métier dans une entité sans en faire une entité à part entière. Il n’a pas sa propre table, pas son propre cycle de vie, pas besoin de repository dédié. Il est simplement embarqué dans l’entité qui l’utilise.
Dans notre cas, on peut créer un objet Money, proche d’un Value Object, qui porte toute la logique liée au prix :
#[ORM\Embeddable]
final class Money
{
#[ORM\Column]
private int $amountCents;
#[ORM\Column(length: 3)]
private string $currency;
public function __construct(int $amountCents, string $currency = 'EUR')
{
if ($amountCents <= 0) {
throw new \InvalidArgumentException('Money amount must be greater than zero.');
}
$currency = strtoupper(trim($currency));
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException('Money currency must be a valid ISO 4217 code.');
}
$this->amountCents = $amountCents;
$this->currency = $currency;
}
}Ensuite, dans l’entité Product, on embarque simplement cet objet :
#[ORM\Embedded(class: Money::class, columnPrefix: 'price_')]
private Money $price;Doctrine va alors générer les colonnes directement dans la table product, par exemple :
price_amount_cents
price_currencyC’est beaucoup plus propre qu’une duplication de champs dans chaque entité.
L’avantage est double : on garde une base de données simple, tout en centralisant une vraie logique métier dans un objet réutilisable. Le produit n’a plus à savoir comment valider une devise ou comment représenter un montant. Il manipule simplement un objet Money.
On obtient donc un modèle plus lisible, plus maintenable et surtout plus évolutif.
C’est aussi une manière de mieux découper son domaine sans tomber dans l’excès. On ne crée pas une table moneyinutilement, mais on évite aussi de disperser la logique du prix partout dans le code.
Dans un projet Symfony avancé, ce genre d’approche devient très utile pour tout ce qui représente une valeur métier réutilisable :
#[ORM\Embedded(class: Money::class, columnPrefix: 'price_')]
private Money $price;On peut ensuite imaginer le même principe pour :
- une adresse ;
- une période de dates ;
- une coordonnée GPS ;
- un intervalle de prix ;
- une devise ;
- une configuration métier ;
- une mesure physique ;
- un objet de contact.
L’important est de comprendre que l’Embeddable n’est pas juste une astuce Doctrine. C’est un outil de modélisation.
Il permet de sortir une logique répétée d’une entité, de l’unifier dans un objet dédié, puis de la distiller uniquement là où elle est nécessaire.
C’est exactement le type de fonctionnalité Doctrine qui permet d’aller plus loin qu’un simple CRUD, tout en gardant un code Symfony plus propre, plus expressif et moins coûteux à maintenir.
Cache L2 Doctrine : diviser une requête par 5 facilement
La mise en cache est souvent présentée comme une solution magique pour améliorer les performances. En réalité, tout mettre en cache est rarement une bonne idée.
Le vrai sujet, ce n’est pas de cacher partout.
Le vrai sujet, c’est d’identifier les zones critiques de l’application où la donnée change peu, mais est lue très souvent.
Prenons un exemple simple : un blog.
Une fois qu’un article est publié, son contenu devient relativement stable. Il peut être modifié plus tard, bien sûr, mais il ne change pas à chaque requête HTTP. Dans ce cas, pourquoi interroger la base de données à chaque affichage d’une liste d’articles déjà prête à être consommée ?
C’est exactement le type de scénario où le cache de second niveau Doctrine, aussi appelé Doctrine Second Level Cache ou cache L2, devient très intéressant.
Contrairement au cache applicatif classique, le cache L2 agit directement au niveau de Doctrine. Il permet de stocker certaines entités, collections ou associations afin d’éviter de reconstruire systématiquement les mêmes objets depuis la base de données.
Dans notre cas, on peut par exemple mettre en cache des produits :
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE', region: 'products')]
class Product
{
// ...
}Avec cette configuration, Doctrine peut réutiliser les données mises en cache au lieu de refaire systématiquement une requête SQL complète.
Sur un cas simple, on peut déjà observer un gain très net : moins de requêtes, moins de temps passé côté base de données, moins de charge serveur.
Dans l’exemple présenté ici, le profiler Symfony montre que Doctrine ne gère qu’une seule entité et que le cache commence déjà à être utilisé :
Cache hits: 1
Cache misses: 0
Cache puts: 1C’est là que le cache L2 devient puissant : il permet d’éviter de solliciter inutilement la base sur des données stables.
Avec un stockage local sur disque ou SSD, le gain peut déjà être visible. Avec un backend plus adapté comme Redis, le résultat peut être encore meilleur, notamment sur des applications avec beaucoup de lectures répétées.
Mais attention : le cache L2 n’est pas une solution à appliquer partout.
Il est surtout pertinent pour :
- des données rarement modifiées ;
- des listes très consultées ;
- des référentiels ;
- des catalogues produits ;
- des contenus publiés ;
- des taxonomies ;
- des configurations métier.En revanche, il est beaucoup moins adapté aux données très dynamiques : paniers utilisateurs, stocks temps réel, statistiques instantanées ou informations personnalisées par utilisateur.
Le bon usage du cache L2, c’est donc de cibler précisément les lectures répétées sur des données stables.
Bien utilisé, il peut réellement diviser le coût d’accès à certaines données par 5, voire plus selon l’infrastructure et le backend de cache utilisé. Mais surtout, il évite à Symfony et Doctrine de refaire un travail inutile à chaque requête.
Et c’est exactement l’objectif d’un usage avancé de Doctrine : ne pas surcharger le framework quand la donnée est déjà disponible.
Les filtres Doctrine : késako ?
Les filtres Doctrine permettent d’ajouter automatiquement une condition SQL à certaines requêtes, sans avoir à la répéter dans chaque repository, chaque controller ou chaque méthode métier.
C’est un outil discret, mais extrêmement puissant.
Prenons un cas très classique : le soft delete.
Dans une application e-commerce, un produit peut être supprimé du catalogue. Pourtant, ce produit ne doit pas forcément disparaître complètement de la base de données. Des commandes peuvent encore y être liées, des factures peuvent exister, et un client peut avoir besoin de faire jouer une garantie plusieurs mois après son achat.
Supprimer réellement le produit serait donc dangereux.
La bonne approche consiste souvent à le marquer comme supprimé via un champ deletedAt, puis à le masquer automatiquement sur le front-office.
C’est exactement ce que fait ce filtre :
final class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
$reflectionClass = $targetEntity->getReflectionClass();
if (!$reflectionClass->implementsInterface(SoftDeletableInterface::class)) {
return '';
}
$column = $this->getConnection()->getDatabasePlatform()->quoteIdentifier(
$targetEntity->getColumnName('deletedAt'),
);
return sprintf('%s.%s IS NULL', $targetTableAlias, $column);
}
}ici, Doctrine vérifie si l’entité implémente SoftDeletableInterface.
Si ce n’est pas le cas, le filtre ne fait rien.
En revanche, si l’entité est concernée, Doctrine ajoute automatiquement cette contrainte SQL :
deleted_at IS NULLRésultat : tous les produits supprimés logiquement ne remontent plus dans les requêtes classiques.
L’intérêt est énorme : la règle métier devient globale, lisible et centralisée. On n’a plus besoin d’ajouter partout :
andWhere('product.deletedAt IS NULL')Le filtre s’applique directement au niveau Doctrine, donc il fonctionne de manière transversale dans l’application.
C’est particulièrement utile pour des besoins métier sérieux :
- masquer des produits supprimés du catalogue ;
- conserver l’historique des commandes ;
- garder les factures cohérentes ;
- éviter la suppression physique de données sensibles ;
- appliquer des règles globales selon le contexte ;
- filtrer automatiquement des contenus visibles ou non visibles.Et ce mécanisme ne se limite pas au soft delete.
On peut imaginer des filtres Doctrine pour des cas beaucoup plus avancés : filtrer des données par tenant dans une application SaaS, limiter l’accès à certaines ressources selon une période, ou encore cibler des clients ayant acheté entre deux dates pour une campagne newsletter.
Par exemple, une opération commerciale du 1er au 8 mai pourrait s’appuyer sur un filtre global afin d’identifier certains profils clients et leur appliquer automatiquement une logique dédiée : code promo, segmentation marketing, visibilité spécifique ou relance personnalisée.
Ce qui rend les filtres Doctrine intéressants, c’est leur capacité à appliquer une règle au plus près de la donnée, sans polluer les controllers ni dupliquer la logique dans toute l’application.
Bien utilisés, ils rendent le code plus scalable, plus lisible et beaucoup plus robuste.
Mal utilisés, en revanche, ils peuvent rendre certaines requêtes difficiles à comprendre, car une partie du SQL est ajoutée automatiquement.
C’est donc un outil avancé à utiliser avec intention : parfait pour des règles globales, stables et transversales, mais à éviter pour une logique trop ponctuelle ou trop spécifique à une seule pag
Conclusion
Nous avons déjà parcouru pas mal de terrain dans ce premier article autour d’un usage avancé de Symfony et de Doctrine ORM.
Et honnêtement, beaucoup de ces fonctionnalités restent encore assez peu mises en avant, que ce soit dans la documentation officielle ou même dans une partie de la communauté. Pourtant, ce sont précisément ces outils qui permettent de faire passer une application Symfony d’un simple CRUD fonctionnel à une architecture réellement robuste et scalable.
Au fil de cet article, nous avons vu comment :
- éviter la duplication de logique métier grâce aux
Embeddable; - centraliser des comportements transverses avec les filtres Doctrine ;
- améliorer les performances avec le cache L2 ;
- mieux comprendre le coût réel d’un ORM ;
- et surtout reprendre le contrôle sur la manière dont Doctrine manipule les données.
L’objectif n’était pas de “faire de la magie” avec Doctrine, mais au contraire de comprendre ce qu’il fait réellement sous le capot afin d’éviter de surcharger inutilement Symfony.
Parce qu’au final, Doctrine n’est pas lent.
Un mauvais usage de Doctrine, en revanche, peut rapidement le devenir.
Bien sûr, Doctrine propose encore énormément d’autres sujets avancés :
- hydratation partielle ;
- QueryBuilder complexe ;
- DTO ;
- projections ;
- batch processing ;
- UnitOfWork ;
- optimisation mémoire ;
- SQL natif ;
- stratégies de chargement ;
- ou encore architecture hybride ORM/DBAL.
Et justement, ce sera l’objectif des prochains articles de cette série.
Si tu développes régulièrement avec Symfony, comprendre ces concepts peut réellement changer la manière dont tu construis tes applications : moins de duplication, moins de requêtes inutiles, moins de dette technique et beaucoup plus de maîtrise sur les performances.
Et toi, est-ce que cet article t’a appris quelque chose ?
Est-ce qu’il t’a permis d’aller plus loin que l’utilisation “classique” de Doctrine ?
Laisser un commentaire