, ,
19 min de lecture

Doctrine avancé : Proxies, Lazy Loading et Transactions

doctrine avance proxies transaction par l'exemple

Introduction

Comme dans mon précédent article consacré aux fonctionnalités avancées de Doctrine, il est parfois nécessaire de replonger dans les mécanismes internes des outils que nous utilisons quotidiennement. Et s’il y a bien un composant qui mérite que l’on s’y attarde de temps à autre, c’est Doctrine ORM.

Après avoir exploré les soft deletes, les mécanismes de cache ou encore les embeddables, nous allons cette fois nous intéresser à deux concepts fondamentaux mais souvent mal compris : les proxies et les transactions.

Derrière la simplicité apparente d’un appel à find(), d’un accès à une relation ou d’un simple flush(), Doctrine met en œuvre de nombreux mécanismes destinés à optimiser les performances, garantir la cohérence des données et simplifier le travail du développeur.

Nous verrons notamment comment les proxies permettent le chargement paresseux (lazy loading), comment Doctrine gère l’Identity Map en mémoire, mais également pourquoi un simple flush() ne suffit pas toujours lorsque plusieurs opérations doivent être exécutées de manière atomique.

Comme toujours, nous nous appuierons sur des exemples concrets, du code exécutable et des démonstrations reproductibles afin de comprendre ce qui se passe réellement sous le capot.

Les proxies : ce n’est pas qu’une histoire de DevOps

Lorsqu’on parle de proxy, beaucoup pensent immédiatement aux infrastructures réseau, aux reverse proxies ou encore aux API gateways. Pourtant, dans Doctrine, les proxies jouent un rôle tout aussi important, même s’ils interviennent à un niveau complètement différent.

Pour comprendre leur intérêt, il faut déjà distinguer deux opérations que nous réalisons constamment avec un ORM :

  • la création d’une donnée ;
  • la récupération d’une donnée existante.

Dans les deux cas, Doctrine cherche à limiter le travail effectué tant que celui-ci n’est pas nécessaire. C’est le principe du lazy loading, ou chargement paresseux.

Le fonctionnement est particulièrement visible lorsque des entités sont liées entre elles. Dans notre exemple, un Articlepossède une relation ManyToOne vers un Author.

Création des données

La route suivante crée un auteur ainsi qu’un article associé :

#[Route('/create', name: 'create', methods: ['GET'])]
public function create(Request $request, EntityManagerInterface $entityManager): Response
{
    $suffix = date('His').'-'.bin2hex(random_bytes(2));

    $author = new Author('Doctrine author '.$suffix);
    $article = new Article('Lazy loading article '.$suffix, $author);

    $entityManager->persist($author);
    $entityManager->persist($article);
    $entityManager->flush();

    return $this->respond(
        $request,
        'demo/doctrine/proxy/create.html.twig',
        [
            'action' => 'proxy-create',
            'author' => $author->toArray(),
            'article' => $article->toArray(),
        ],
        Response::HTTP_CREATED
    );
}

Ici, rien de spectaculaire.

Doctrine enregistre simplement les deux entités lors du flush(). Les lignes sont écrites en base et les objets sont déjà présents dans l’EntityManager courant.

À ce stade, il est impossible d’observer le comportement du lazy loading puisque l’auteur est déjà connu de l’Identity Map. Doctrine n’a donc aucune raison d’aller le rechercher.

C’est pour cette raison que la démonstration utilise une seconde requête HTTP.

Le payload retourné contient l’identifiant de l’article nouvellement créé, qui servira pour l’étape suivante :

/demo/doctrine/proxies/articles/{id}

Récupération de l’article

Dans une nouvelle requête HTTP, Symfony crée un nouvel EntityManager.

Cette fois, Doctrine doit reconstruire les objets à partir des données stockées en base.

#[Route('/articles/{id}', name: 'show', methods: ['GET'])]
public function show(
    Request $request,
    Article $article,
    EntityManagerInterface $entityManager
): Response {
    $author = $article->getAuthor();

    $uninitializedBefore = $entityManager->isUninitializedObject($author);

    $reference = $entityManager->getReference(
        Author::class,
        $author->getId()
    );

    $sameIdentityInstance = $reference === $author;

    $authorName = $author->getName();

    return $this->respond(
        $request,
        'demo/doctrine/proxy/show.html.twig',
        [
            'action' => 'proxy-initialization',
            'native_lazy_objects' => $entityManager
                ->getConfiguration()
                ->isNativeLazyObjectsEnabled(),
            'article' => $article->toArray(),
            'author_type' => get_debug_type($author),
            'uninitialized_before' => $uninitializedBefore,
            'uninitialized_after' => $entityManager->isUninitializedObject($author),
            'same_identity_instance' => $sameIdentityInstance,
            'author_name' => $authorName,
        ]
    );
}
initialiser un objet lazy avec doctrine

Observer un proxy avant son initialisation

Lorsque Doctrine charge l’article, il n’a pas forcément besoin des informations complètes de l’auteur.

Dans un premier temps, seule la clé étrangère est nécessaire.

L’objet Author est donc représenté par une référence non initialisée. Il existe déjà en mémoire, mais ses données n’ont pas encore été chargées depuis la base.

C’est précisément ce que permet de vérifier la méthode :

$entityManager->isUninitializedObject($author);

Cette vérification est particulièrement intéressante car elle permet d’observer l’état du proxy sans provoquer son chargement.

Le moment où Doctrine exécute réellement la requête

La situation change dès que l’on demande une information qui n’est pas encore disponible.

L’appel suivant suffit :

$author->getName();

À cet instant, Doctrine déclenche automatiquement une requête SQL supplémentaire pour récupérer l’auteur.

Le proxy est alors initialisé et contient désormais l’ensemble des données de l’entité.

Si l’on exécute à nouveau :

$entityManager->isUninitializedObject($author);

la méthode retourne désormais false.

Le profiler Doctrine permet d’ailleurs d’observer très facilement cette requête supplémentaire et de comprendre précisément à quel moment elle est exécutée.

L’Identity Map en action

La démonstration met également en évidence un autre mécanisme essentiel de Doctrine : l’Identity Map.

Lorsque l’on exécute :

$entityManager->getReference(
    Author::class,
    $author->getId()
);

Doctrine ne crée pas une nouvelle instance.

Il retrouve l’objet déjà présent dans l’EntityManager et retourne exactement la même référence mémoire.

La comparaison :

$reference === $author

retourne donc true.

Cette garantie est fondamentale pour assurer la cohérence des données durant tout le cycle de vie d’un EntityManager.

Et avec PHP 8.4 ?

Les développeurs habitués aux anciennes versions de Doctrine ont souvent connu les classes générées ressemblant à :

Proxies\__CG__\App\Entity\Author

Ces sous-classes servaient à implémenter le chargement paresseux.

Avec Doctrine ORM 3.6 et PHP 8.4+, le mécanisme évolue grâce aux Native Lazy Objects.

Dans ce cas, le type affiché peut rester simplement :

App\Entity\Author

Le comportement lazy est toujours présent, mais il repose désormais directement sur les capacités du moteur PHP plutôt que sur des classes proxy générées automatiquement.

Le résultat est plus transparent, plus performant et beaucoup plus simple à maintenir.

Les transactions complexes : reprendre le contrôle proprement

Dans la majorité des cas, le duo persist() puis flush() suffit largement. On crée une entité, on la confie à Doctrine, puis on synchronise les changements avec la base de données.

Mais certains scénarios demandent un contrôle plus fin.

Par exemple, lorsque plusieurs écritures doivent absolument réussir ensemble, lorsqu’une vérification métier doit être faite avant validation, ou lorsqu’un traitement externe peut provoquer une erreur au milieu du processus.

Dans ce genre de situation, il ne suffit plus seulement de dire à Doctrine : « écris ce que tu as en mémoire ».

Il faut définir une frontière claire :

soit toutes les opérations réussissent, soit aucune ne reste en base.

C’est précisément le rôle de wrapInTransaction().

$entityManager->wrapInTransaction(function (EntityManagerInterface $entityManager): void {
    // opérations atomiques
});

Cette méthode encadre explicitement plusieurs opérations dans une transaction. Si le callback se termine normalement, Doctrine exécute le flush() final puis le COMMIT.

En revanche, si une exception est levée, Doctrine déclenche un ROLLBACK et annule l’ensemble des écritures de la transaction.

Attention toutefois : cela ne veut pas dire qu’il faut utiliser wrapInTransaction() partout.

Pour une création simple, un flush() classique reste plus lisible et largement suffisant. wrapInTransaction() devient intéressant lorsque l’on veut exprimer clairement une atomicité métier plus large.

Une classe de démonstration

Pour rendre ce comportement observable, j’ai créé une petite classe dédiée :

<?php

namespace App\Application\Doctrine;

use App\Entity\Article;
use App\Entity\Author;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;

final class TransactionDemo
{
    public function __construct(
        private readonly ManagerRegistry $doctrine,
    ) {
    }

    public function commit(): array
    {
        $entityManager = $this->entityManager();
        $marker = $this->marker('commit');

        $created = $entityManager->wrapInTransaction(
            function (EntityManagerInterface $entityManager) use ($marker): array {
                $author = new Author('Author '.$marker);
                $article = new Article('Article '.$marker, $author);

                $entityManager->persist($author);
                $entityManager->persist($article);

                return ['author' => $author, 'article' => $article];
            }
        );

        return [
            'action' => 'transaction-commit',
            'marker' => $marker,
            'committed' => true,
            'author' => $created['author']->toArray(),
            'article' => $created['article']->toArray(),
            'rows_after' => $this->countMarkerRows($entityManager, $marker),
            'entity_manager_open' => $entityManager->isOpen(),
        ];
    }

    public function rollback(): array
    {
        $entityManager = $this->entityManager();
        $marker = $this->marker('rollback');
        $exceptionMessage = null;

        try {
            $entityManager->wrapInTransaction(
                function (EntityManagerInterface $entityManager) use ($marker): void {
                    $author = new Author('Author '.$marker);
                    $article = new Article('Article '.$marker, $author);

                    $entityManager->persist($author);
                    $entityManager->persist($article);
                    $entityManager->flush();

                    throw new \RuntimeException(
                        'Failure triggered after flush() and before commit().'
                    );
                }
            );
        } catch (\RuntimeException $exception) {
            $exceptionMessage = $exception->getMessage();
        }

        $closedAfterFailure = !$entityManager->isOpen();
        $freshEntityManager = $this->resetEntityManager();

        return [
            'action' => 'transaction-rollback',
            'marker' => $marker,
            'committed' => false,
            'rolled_back' => true,
            'exception' => $exceptionMessage,
            'flush_was_executed' => true,
            'entity_manager_closed_after_failure' => $closedAfterFailure,
            'entity_manager_reset' => $freshEntityManager->isOpen(),
            'rows_after' => $this->countMarkerRows($freshEntityManager, $marker),
        ];
    }

    private function entityManager(): EntityManagerInterface
    {
        $manager = $this->doctrine->getManager();

        if (!$manager instanceof EntityManagerInterface) {
            throw new \LogicException(
                'The default Doctrine manager must be an ORM entity manager.'
            );
        }

        return $manager;
    }

    private function resetEntityManager(): EntityManagerInterface
    {
        $manager = $this->doctrine->resetManager();

        if (!$manager instanceof EntityManagerInterface) {
            throw new \LogicException(
                'The reset Doctrine manager must be an ORM entity manager.'
            );
        }

        return $manager;
    }

    private function countMarkerRows(
        EntityManagerInterface $entityManager,
        string $marker
    ): array {
        return [
            'authors' => $entityManager
                ->getRepository(Author::class)
                ->count(['name' => 'Author '.$marker]),
            'articles' => $entityManager
                ->getRepository(Article::class)
                ->count(['title' => 'Article '.$marker]),
        ];
    }

    private function marker(string $scenario): string
    {
        return sprintf('%s-%s', $scenario, bin2hex(random_bytes(5)));
    }
}

Cas 1 : le commit

Dans le premier scénario, on crée un auteur et un article dans une transaction.

Aucun flush() explicite n’est nécessaire dans le callback :

$created = $entityManager->wrapInTransaction(function (...) {
    $entityManager->persist($author);
    $entityManager->persist($article);

    return ['author' => $author, 'article' => $article];
});

Lorsque le callback se termine sans exception, Doctrine exécute le flush() puis valide la transaction avec un COMMIT.

Le payload permet ensuite de vérifier que les deux lignes existent bien en base :

'rows_after' => $this->countMarkerRows($entityManager, $marker),

Le résultat attendu est clair :

authors: 1
articles: 1

Les deux écritures ont été validées ensemble.

Cas 2 : le rollback après flush

Le second scénario est plus intéressant.

Cette fois, on force volontairement une erreur après un flush() :

$entityManager->persist($author);
$entityManager->persist($article);
$entityManager->flush();

throw new \RuntimeException(
    'Failure triggered after flush() and before commit().'
);

À première vue, on pourrait croire que les données sont déjà enregistrées, puisque le flush() a envoyé les INSERT à PostgreSQL.

Mais elles ne sont pas encore validées.

Elles restent dans la transaction ouverte par wrapInTransaction().

Lorsque l’exception est levée, Doctrine déclenche un ROLLBACK. Les écritures sont annulées, même si le flush() a bien été exécuté.

Le payload le montre explicitement :

'flush_was_executed' => true,
'rolled_back' => true,
'rows_after' => $this->countMarkerRows($freshEntityManager, $marker),

Le résultat attendu est donc :

authors: 0
articles: 0

C’est le point essentiel à retenir : flush() synchronise les changements avec la base, mais seul le COMMIT les rend définitifs dans une transaction.

Après une erreur : l’EntityManager est fermé

Doctrine prend également une précaution importante après une erreur transactionnelle : l’EntityManager est fermé.

$closedAfterFailure = !$entityManager->isOpen();

Cela évite de continuer à travailler avec une Unit of Work potentiellement incohérente.

Pour repartir proprement, il faut récupérer un manager neuf :

$freshEntityManager = $this->resetEntityManager();

C’est ce que fait ici ManagerRegistry::resetManager().

Le code peut ensuite vérifier la base avec un EntityManager propre et confirmer qu’aucune ligne du scénario échoué n’a été conservée.

Flush ou wrapInTransaction ?

La différence peut se résumer ainsi :

flush()
= synchroniser la Unit of Work avec la base

wrapInTransaction()
= définir une frontière atomique autour de plusieurs opérations

Il ne faut donc pas voir wrapInTransaction() comme un remplacement systématique de flush().

C’est plutôt un outil à utiliser lorsque le métier exige une garantie forte : toutes les opérations passent ensemble, ou tout est annulé.


Proxies et transactions : le duo gagnant des métiers complexes

Maintenant que nous avons vu le fonctionnement des proxies et des transactions, la question devient naturellement : dans quels cas ces mécanismes apportent-ils une réelle valeur métier ?

Prenons un exemple concret : une application bancaire.

Pour des raisons historiques ou réglementaires, il n’est pas rare de trouver une architecture reposant encore sur des services SOAP synchrones. Ces appels sont souvent lourds, parfois lents, mais extrêmement sécurisés car ils pilotent des opérations sensibles : virements, validation de documents, consultation de comptes ou encore signature électronique.

Dans ce contexte, chaque requête compte.

L’objectif n’est plus seulement de faire fonctionner l’application, mais de garantir à la fois les performances, la cohérence des données et la sécurité des opérations.

C’est précisément là que les fonctionnalités avancées de Doctrine prennent tout leur sens.

Éviter les chargements inutiles

Un utilisateur ouvre son application mobile pour consulter la liste de ses opérations.

A-t-on réellement besoin de charger immédiatement :

  • les explications détaillées de chaque paiement ;
  • les pièces justificatives associées ;
  • les commentaires internes ;
  • l’ensemble des relations liées à chaque mouvement ?

Probablement pas.

Grâce au lazy loading, seules les données nécessaires à l’affichage initial sont récupérées.

Les informations détaillées ne seront chargées que lorsque l’utilisateur ouvrira réellement une opération.

Sur une liste contenant plusieurs dizaines voire centaines de mouvements, le gain peut être considérable.

Réutiliser intelligemment les données

Comme nous l’avons vu dans l’article précédent, le cache reste également un allié précieux.

Si aucune nouvelle opération n’est arrivée depuis la dernière consultation :

  • inutile d’interroger les systèmes bancaires internes ;
  • inutile de solliciter les services SOAP ;
  • inutile de reconstruire les mêmes objets.

Le cache permet alors de réduire la charge tout en améliorant le temps de réponse perçu par l’utilisateur.

Utiliser les embeddables pour les données métier secondaires

Toutes les informations manipulées par une application bancaire ne concernent pas directement les transactions financières.

Prenons un exemple simple : le suivi des fonctionnalités utilisées dans l’application mobile.

On peut vouloir enregistrer :

  • la dernière connexion ;
  • les fonctionnalités les plus utilisées ;
  • les préférences d’affichage ;
  • certains indicateurs de navigation.

Ce type d’information s’intègre parfaitement dans un Embeddable, sans multiplier inutilement les entités ou les tables dédiées.

Le modèle métier reste plus propre et plus facile à maintenir.

Sécuriser les opérations critiques avec les transactions

Le véritable intérêt apparaît lorsqu’une opération métier devient critique.

Imaginons qu’un utilisateur souhaite envoyer un message sécurisé à son conseiller bancaire avec plusieurs documents en pièce jointe.

Avant de valider l’opération, il faut s’assurer que :

  • tous les champs obligatoires sont présents ;
  • les documents ont bien été téléversés ;
  • les formats sont valides ;
  • les contrôles de sécurité ont été exécutés ;
  • les éventuels appels aux services externes ont réussi.

Dans ce scénario, effectuer un flush() trop tôt serait risqué.

Une transaction permet au contraire de regrouper toutes les vérifications et toutes les écritures dans une seule unité atomique.

Si une étape échoue :

  • aucun document n’est conservé ;
  • aucun message n’est enregistré ;
  • aucune donnée partielle ne subsiste.

L’application reste cohérente.

Une approche plus proche des besoins réels

Pris individuellement, les proxies, le cache, les embeddables ou les transactions peuvent sembler être des optimisations techniques.

En réalité, lorsqu’ils sont combinés, ils permettent surtout de mieux représenter les contraintes du métier.

Les proxies évitent les chargements inutiles.

Le cache réduit les appels coûteux.

Les embeddables simplifient le modèle métier.

Les transactions garantissent l’intégrité des opérations critiques.

C’est cette combinaison qui permet de construire des applications robustes, performantes et adaptées à des domaines exigeants comme la banque, l’assurance, la santé ou encore les plateformes de paiement.

doctrine avancé à la fin de la l'article 2

Conclusion

Si l’on prend un peu de recul sur cet article, mon objectif était surtout de rendre accessibles des concepts que l’on croise régulièrement dans Doctrine sans forcément prendre le temps de les observer en détail.

Les proxies, le lazy loading, l’Identity Map ou encore les transactions font partie de ces mécanismes qui restent souvent invisibles tant que tout fonctionne correctement. Pourtant, comprendre leur fonctionnement permet de mieux anticiper certains comportements, d’optimiser ses accès aux données et de construire des traitements métier plus robustes.

Comme toujours, je ne prétends pas détenir la seule bonne façon de faire. Chaque projet possède ses contraintes techniques, son historique et ses propres choix d’architecture. L’idée était avant tout de montrer, à travers des exemples concrets, quand ces outils deviennent réellement utiles et pourquoi Doctrine les met à notre disposition.

Même si un ORM reste une abstraction entre le code applicatif et la base de données, cette abstraction devient beaucoup plus confortable lorsque l’on comprend ce qui se passe derrière le rideau.

Avec les années, Doctrine est devenu une véritable brique de l’écosystème Symfony. Derrière la simplicité apparente d’un find(), d’un persist() ou d’un flush(), on découvre finalement une mécanique particulièrement riche et bien pensée.

Cette phase 2 consacrée aux proxies et aux transactions clôture également cette série d’exploration des fonctionnalités avancées de Doctrine. Entre les précédents sujets et ceux abordés ici, vous disposez désormais d’une vision beaucoup plus complète de ce que l’ORM est capable de faire.

Et comme souvent lorsque l’on commence à regarder sous le capot, de nouvelles pistes apparaissent rapidement.

J’ai déjà quelques idées pour la suite.