Doctrine est un ORM puissant, mais la plupart des développeurs n'utilisent que 20 % de ses capacités. Voici 3 patterns que les développeurs seniors appliquent systématiquement — et qui peuvent diviser par 10 le temps de certaines requêtes.
Pattern 1 : Read Models avec DTOs (adieu l'hydratation complète)
Le réflexe junior : charger l'entité complète, même quand on n'a besoin que du nom et de l'email. Chaque findAll() hydrate tous les champs, toutes les relations lazy-loaded. C'est du gaspillage de mémoire et de CPU.
<?php
// AVANT (junior) — Hydratation complète
$users = $this->userRepository->findAll();
// Charge TOUTES les propriétés, TOUTES les relations
// Résultat : 50+ colonnes hydratées, dont 45 inutiles
foreach ($users as $user) {
$names[] = $user->getName(); // On voulait juste ça
}<?php
// APRÈS (senior) — DTO projection
final readonly class UserListDTO
{
public function __construct(
public string $id,
public string $name,
public string $email,
) {}
}
// Dans le repository
public function findAllForList(): array
{
return $this->createQueryBuilder('u')
->select('NEW App\\Presentation\\DTO\\UserListDTO(u.id, u.name, u.email)')
->getQuery()
->getResult();
// Résultat : 3 colonnes, pas d'hydratation Entity
// Performance : 3-10x plus rapide sur des tables volumineuses
}La syntaxe NEW de DQL crée directement des DTOs sans passer par l'hydratation d'entité. C'est le pattern Read Model du CQRS appliqué à Doctrine.
Pattern 2 : Eager loading intelligent (zéro N+1)
Le problème N+1 est le tueur de performance numéro 1. Tu charges 100 commandes, puis pour chacune, Doctrine fait une requête pour charger les items. Résultat : 101 requêtes au lieu d'une seule.
<?php
// AVANT (junior) — N+1 queries
$orders = $this->orderRepository->findAll();
// 1 requête
foreach ($orders as $order) {
$items = $order->getItems(); // +1 requête par commande !
foreach ($items as $item) {
$product = $item->getProduct(); // +1 requête par item !!
}
}
// Total : 1 + 100 + 500 = 601 requêtesBesoin d'un expert Symfony ?
Réserver un appel →<?php
// APRÈS (senior) — JOIN fetch en une requête
public function findWithRelations(): array
{
return $this->createQueryBuilder('o')
->select('o', 'items', 'product')
->leftJoin('o.items', 'items')
->leftJoin('items.product', 'product')
->getQuery()
->getResult();
// Total : 1 requête avec JOINs
// Performance : de 601 requêtes à 1
}La clé : toujours inclure les relations dans le SELECT. ->select('o', 'items', 'product') force Doctrine à tout charger en une seule requête.
Pattern 3 : Event Listeners pour audit et soft delete
Le réflexe junior : ajouter du code d'audit manuellement dans chaque service. Le senior utilise les lifecycle events de Doctrine pour centraliser cette logique.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Doctrine\EventListener;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Psr\Log\LoggerInterface;
#[AsDoctrineListener(event: Events::preUpdate)]
final readonly class AuditListener
{
public function __construct(
private LoggerInterface $auditLogger,
) {}
public function preUpdate(PreUpdateEventArgs $args): void
{
$entity = $args->getObject();
$changes = $args->getEntityChangeSet();
$this->auditLogger->info('Entity updated', [
'class' => $entity::class,
'id' => method_exists($entity, 'id') ? (string) $entity->id() : '?',
'changes' => array_keys($changes),
]);
}
}Avec les PHP 8 Attributes et #[AsDoctrineListener], plus besoin de configuration YAML. Le listener est automatiquement détecté par l'autowiring.
Ces 3 patterns sont la base d'un projet Doctrine performant. Si tu veux un audit complet de tes requêtes Doctrine et de ton architecture Symfony, Bear Upgrade inclut une analyse détaillée avec des recommandations chiffrées.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.