Blog
Doctrine UnitOfWork — Quand les entités ne se comportent plus comme prévu
Une entité change côté PHP, mais la base ne bouge pas. La cause est souvent l’état de cette instance dans l’EntityManager courant, pas le setter.
Un bug qui semble impossible
La commande indique qu’elle est payée, flush() se termine sans erreur, mais sa ligne en base n’a pas changé. Une explication possible : l’objet PHP a survécu au contexte de persistance qui suivait ses modifications.
Cet exemple suppose une commande existante non payée, un champ de statut ordinaire correctement mappé et un repository utilisant le même EntityManager ouvert que le reste du code. markAsPaid() modifie ce champ, sans exécuter son propre SQL ni réinscrire l’objet auprès de Doctrine.
$order = $orderRepository->find($id);
if ($order === null) {
throw new OrderNotFound($id);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();OrderNotFound est une exception du projet, pas une classe Doctrine. Les entités et le repository omis servent à illustrer le problème ; il ne s’agit pas du récit d’un incident vérifié chez un client GiSoft.
clear() détache la commande chargée. Il ne détruit pas l’objet PHP et ne réinitialise pas ses propriétés : $order->getStatus() peut donc ensuite retourner paid. En revanche, le flush() suivant ne programme pas de mise à jour pour cette instance détachée. D’autres opérations enregistrées après clear() pourraient toujours être écrites.
Dans ce cas, l’état de l’objet et sa participation à la persistance divergent :
Opération Statut PHP État Écriture du statut
find() unpaid MANAGED —
clear() unpaid DETACHED —
markAsPaid() paid DETACHED —
flush() paid DETACHED aucun UPDATEUn premier contrôle utile consiste à examiner :
dump($order->getStatus());
dump($entityManager->contains($order));Les valeurs sont ici paid et false. La seconde signifie que cet EntityManager ne gère pas actuellement l’instance au sens de contains(). Elle ne prouve pas à elle seule l’état DETACHED : un objet nouveau non enregistré ou une entité dont la suppression est programmée peut aussi retourner false. Une instance gérée par un autre EntityManager n’est pas automatiquement gérée ici non plus. Dans l’exemple initial, son historique de chargement puis d’effacement du contexte permet d’identifier le détachement.
Ce que suit l’UnitOfWork
L’UnitOfWork tient les informations de suivi de l’EntityManager : identités des instances, valeurs suivies et opérations en attente sur les entités ou collections. Avec la politique par défaut DEFERRED_IMPLICIT, elle compare les valeurs mappées pendant le flush pour déterminer les écritures nécessaires. Elle ne parcourt pas toutes les variables PHP ni toutes les propriétés des services.
Le suivi est configurable. Avec DEFERRED_EXPLICIT, les entités gérées doivent être désignées pour la détection des changements par persist() ou une cascade appropriée. Les mappings en lecture seule, le côté propriétaire d’une association et le type de valeur modifiée comptent également. Être gérée est nécessaire pour une mise à jour ordinaire suivie par Doctrine, mais chaque appel de méthode ne produit pas forcément un UPDATE.
Les quatre états décrivent l’instance par rapport à un contexte de persistance :
État Signification dans ce contexte
NEW Entité nouvelle, pas encore gérée ;
son INSERT n'est pas encore programmé.
MANAGED Associée à cet EntityManager, hors état REMOVED.
Suivi selon le mapping et la politique choisie.
DETACHED Représente une identité persistante sans être
gérée par cet EntityManager.
REMOVED Entité existante dont la suppression est prévue
lors du flush().Un objet nouvellement enregistré par persist() peut être géré avant même l’insertion de sa ligne. À l’inverse, la présence d’un identifiant ne prouve pas que cet EntityManager gère l’objet. L’état du cycle de vie, les valeurs PHP et le contenu de la base sont liés, sans être interchangeables.
clear() et la carte d’identité des objets
clear() vide l’UnitOfWork, y compris ses changements en attente. Le travail non écrit n’est plus programmé pour la persistance. Les références PHP restent disponibles. Cet appel ne valide ni n’annule une transaction en base et ne ferme pas l’EntityManager : celui-ci peut ensuite charger d’autres objets gérés.
La carte d’identité, ou Identity Map, explique pourquoi conserver l’ancienne référence pose problème. Supposons que l’utilisateur 42 existe et qu’aucun détachement, effacement du contexte ou remplacement du gestionnaire ne survienne entre ces appels :
$userA = $entityManager->find(User::class, 42);
$userB = $entityManager->find(User::class, 42);$userA === $userB vaut alors true. Dans ce contexte, Doctrine associe une identité persistante à une seule instance gérée ; le second find() n’a pas besoin de relire la base. Après clear(), charger cette même identité peut produire une autre instance. L’ancien objet ne redevient pas géré simplement parce que son identifiant correspond.
Un import peut créer une ambiguïté comparable :
$userFromDatabase = $userRepository->find(42);
$userFromPayload = User::fromImportedPayload($payload);User::fromImportedPayload() est une fabrique hypothétique propre au projet. Pour cet exemple, supposons qu’elle construise un objet distinct à partir de données validées décrivant l’utilisateur 42. Son nom ne prouve ni l’identifiant attribué ni l’état Doctrine : cela dépend de son implémentation et du mapping. Même avec le même identifiant, cet objet n’est pas automatiquement l’instance du repository. Les changements autorisés de l’import doivent être appliqués à l’entité gérée, plutôt que de considérer un objet reconstruit quelconque comme une mise à jour de la base.
persist(), flush() et la correction adaptée
Pour une entité mappée réellement nouvelle, ces deux appels ont des responsabilités distinctes :
$entityManager->persist($entity);
$entityManager->flush();persist() rend la nouvelle instance gérée et programme son insertion ; flush() exécute les écritures en attente. Cela ne garantit pas que persist() n’effectue aucun accès à la base : la génération d’un identifiant ou des callbacks peuvent intervenir avant l’INSERT.
Avec le suivi par défaut, une entité existante déjà gérée n’a normalement pas besoin d’un nouveau persist() après modification d’un champ. Le suivi explicite présenté plus haut constitue un autre cas. Appliquer persist() à une entité détachée n’est pas une opération générale de rattachement. Doctrine peut traiter l’objet inconnu comme nouveau, avec une tentative d’insertion ou une erreur à la place de la mise à jour attendue.
D’anciens exemples ORM 2 utilisent merge() pour copier un état détaché dans une instance gérée. Cette opération ne transformait pas simplement l’objet original en instance gérée. La prise en charge de merge a été retirée dans ORM 3 ; la méthode est absente d’ORM 3.6. Ce n’est donc pas une recette actuelle de remise en état.
Pour la commande détachée, utilisez un repository lié à l’EntityManager courant et ouvert. Chargez la commande et appliquez l’opération prévue à l’objet retourné :
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$order->markAsPaid();
$entityManager->flush();Il ne s’agit pas de recopier toutes les propriétés de l’objet périmé. Il faut réévaluer les conditions métier, les droits et, si nécessaire, la version attendue. En présence de modifications concurrentes, utilisez les mécanismes de contrôle de l’application, comme le verrouillage optimiste. find() peut réutiliser un objet déjà géré ou un cache configuré ; il ne garantit pas systématiquement les toutes dernières valeurs validées.
L’extrait suppose que l’appelant est responsable de ce flush et qu’aucun clear ou changement de gestionnaire n’intervient entre-temps. Charger avec un nouvel EntityManager puis effectuer le flush sur l’ancien laisserait le fond du problème intact.
Messenger, entités conservées et sérialisation
Une requête PHP-FPM classique libère ses objets locaux à la fin de son exécution. Un processus Messenger de longue durée peut, lui, réutiliser des services pour plusieurs messages. Une propriété peut donc conserver une entité après l’effacement ou le remplacement de son contexte d’origine :
final class CustomerContext
{
private ?Customer $customer = null;
public function remember(Customer $customer): void
{
$this->customer = $customer;
}
public function customer(): ?Customer
{
return $this->customer;
}
}Cet exemple montre volontairement un état conservé à risque ; il ne comporte aucun mécanisme de réinitialisation. Si CustomerContext survit au changement de contexte Doctrine, customer() peut retourner une ancienne instance. Si le service lui-même est réinitialisé ou recréé, cette voie particulière de conservation peut disparaître.
Symfony propose des mécanismes de réinitialisation des services. Leur fonctionnement dépend toutefois des versions de Symfony et DoctrineBundle, de la configuration, des options du worker et des services de l’application. Tous les workers ne vident ou ne remplacent pas chaque EntityManager de façon identique après chaque message. Lorsqu’un service doit conserver un état, son nettoyage doit s’intégrer au cycle de réinitialisation configuré et être testé sur plusieurs messages successifs.
Les messages devraient généralement transporter des identifiants ou des DTO conçus à cet effet, plutôt que des entités ORM vivantes. Ces appels représentent deux conceptions alternatives de message, pas deux signatures de constructeur d’une même implémentation.
Un message contenant l’entité :
new GenerateInvoiceMessage($invoice);Un message contenant l’identifiant :
new GenerateInvoiceMessage($invoiceId);Dans le second cas, le gestionnaire du message peut charger la facture depuis son repository courant :
$invoice = $invoiceRepository->find($message->invoiceId);
if ($invoice === null) {
throw new InvoiceNotFound($message->invoiceId);
}GenerateInvoiceMessage, sa propriété invoiceId et InvoiceNotFound appartiennent à l’exemple du projet. Le gestionnaire doit encore traiter une éventuelle suppression, vérifier les droits et prendre en compte l’évolution de l’état métier depuis l’envoi. Transmettre un ID ne rend pas, à lui seul, une nouvelle tentative ou un effet externe sûr.
Sérialiser une entité vers une file, une session ou un cache ne transporte pas son appartenance à l’EntityManager. Reconstruire un objet ne l’enregistre pas auprès de l’UnitOfWork destinataire. Cela ne signifie ni que la création synchrone d’un message détache son argument, ni que la sérialisation détache la référence originale encore conservée par l’émetteur.
Le chargement à la demande dépend aussi de la version de l’ORM, du mapping, du mécanisme de proxy et du sérialiseur. Une association non chargée peut devenir inaccessible après transport ou encore solliciter un mécanisme de chargement conservé après un clear. Les valeurs déjà chargées peuvent rester lisibles. Ni un accès réussi ni une exception ne prouvent que l’entité propriétaire est gérée. Les données nécessaires doivent être chargées dans le contexte courant de l’opération, sans compter sur les associations d’un objet détaché pour le réparer. Charger toutes les associations en mode eager ne rattache pas l’objet et peut récupérer des données inutiles.
Qui porte la responsabilité de la transaction ?
Imaginons un contrôleur appelant quatre services : A effectue un flush, B vide l’EntityManager, C modifie la commande précédemment chargée, puis D effectue un autre flush. La question n’est pas le nombre de services, mais l’existence d’une frontière de persistance et de transaction convenue pour leur travail commun.
Quatre niveaux doivent rester distincts : la durée de vie de l’EntityManager, le suivi dans l’UnitOfWork, l’exécution du SQL et la validation ou l’annulation finale de la transaction en base. Sans transaction englobante explicite, un flush comprenant des écritures utilise normalement la gestion transactionnelle implicite de Doctrine. Dans une transaction explicite, un flush() réussi ne prouve pas que la transaction englobante a été validée.
Cette responsabilité peut appartenir à un service applicatif, à un gestionnaire de commande ou à un middleware transactionnel configuré. Le middleware doctrine_transaction de Symfony peut effectuer le flush et le commit après les gestionnaires. Appeler directement un gestionnaire dans un test contourne ce middleware. Avant d’ajouter un flush dans un service imbriqué, il faut savoir qui est responsable de l’écriture.
Un rollback ne remet pas les propriétés PHP à leurs anciennes valeurs. Certains échecs pendant le flush ferment l’EntityManager ; clear() ne le rouvre pas. Pour reprendre le traitement, il faut utiliser le mécanisme de réinitialisation prévu par l’application et abandonner les anciennes références, plutôt que réessayer avec le même gestionnaire fermé. Plusieurs frontières transactionnelles peuvent convenir aux traitements par lots, à condition de prévoir les progrès partiels et leur reprise.
Traiter par lots sans conserver les anciens objets
Un import peut effectuer périodiquement flush et clear pour limiter le nombre d’objets gérés. Il s’agit ici d’un schéma de traitement : le commentaire représente la validation des lignes ainsi que la création ou la modification des entités, avec persist() pour les nouvelles.
$processed = 0;
foreach ($rows as $row) {
// Valider la ligne ; créer ou modifier les entités.
if (++$processed % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();Le compteur ne dépend pas des clés du tableau d’entrée. Le flush final traite le dernier lot incomplet ; le clear final libère les références de l’UnitOfWork. Le traitement de la ligne suivante et les services associés doivent éviter de réutiliser les entités conservées avant ce clear.
Pour des commandes existantes, on peut transporter les identifiants et charger les objets dans le contexte actif :
if ($batchSize < 1) {
throw new \InvalidArgumentException('batchSize >= 1');
}
$processed = 0;
foreach ($orderIds as $orderId) {
$order = $orderRepository->find($orderId);
if ($order === null) {
continue;
}
$order->recalculateTotals();
if (++$processed % $batchSize === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();$batchSize est un entier et $orderIds un itérable d’identifiants valides. L’exemple ignore volontairement les commandes absentes ; un autre processus pourrait devoir les signaler ou arrêter le traitement. Le compteur suit les commandes traitées, pas les positions d’entrée : une absence ne permet donc pas de sauter la limite d’un lot. Le flush final est sans conséquence supplémentaire si le dernier lot a déjà été écrit et qu’aucun travail ne reste en attente.
Les deux exemples supposent un EntityManager dédié et ouvert, le suivi implicite habituel et aucune transaction englobante ni middleware modifiant ces frontières. Chaque lot écrit avec succès peut donc être validé séparément. Un échec ultérieur n’annule pas les commits précédents. Les exceptions interrompent ces schémas ; points de reprise, tentatives limitées, idempotence et récupération après erreur sont omis, pas fournis implicitement.
Clear réduit l’état retenu par Doctrine, pas nécessairement toute la mémoire du processus. Un tableau d’entrée, une propriété de service, un composant de journalisation ou la dernière variable $order peut toujours conserver des objets. Pour de grands volumes, envisagez une lecture progressive des identifiants et mesurez la mémoire. Supprimer clear() uniquement pour masquer le détachement ne résout pas cette question.
Distinguer le contexte, le suivi et la transaction
Si le premier contrôle avec contains() ne suffit pas, recherchez des éléments à plusieurs niveaux. Ce sont des vérifications distinctes : la réussite de l’une ne prouve pas celle de la suivante.
- Identifiez le repository qui a chargé l’instance et l’EntityManager sur lequel le flush est effectué. Retracez clear, close, reset et les références conservées par les services.
isOpen()indique si le gestionnaire est ouvert, pas s’il gère cet objet. - Vérifiez le champ mappé, la politique de suivi, la lecture seule et le côté propriétaire des associations modifiées. Des écouteurs du cycle de vie peuvent aussi changer ou rétablir une valeur.
- Observez le SQL concerné avec un outil de profilage ou un middleware DBAL compatible avec les versions installées. Distinguez l’absence de mise à jour programmée d’une écriture exécutée puis annulée ou suivie d’une exception.
- Confirmez l’issue de la transaction englobante ainsi que la base, l’organisation et la connexion de lecture examinées. Un cache, une réplication retardée ou une autre écriture peut expliquer une valeur différente.
Lorsqu’il faut préciser l’état, ORM 3.6 expose cette API de diagnostic :
$state = $entityManager->getUnitOfWork()->getEntityState($order);Elle retourne une constante Doctrine\ORM\UnitOfWork::STATE_*. Pour une instance inconnue, distinguer une entité nouvelle d’une entité détachée peut nécessiter un accès à la base. Ce résultat complète l’historique de l’objet ; il ne doit pas devenir une règle métier.
L’inspection d’un changeset, l’ensemble des changements détectés, dépend du moment choisi. Avant son calcul normal, il peut être vide ; après un flush réussi, il peut déjà avoir été effacé. Un changeset vide ne prouve donc ni détachement ni échec d’une mise à jour. Modifier les mécanismes internes de l’UnitOfWork pour obtenir le résultat de diagnostic souhaité n’est pas une correction.
Tester l’effet en base, en plus de l’objet
Un test unitaire peut établir qu’une commande non payée change d’état en PHP :
$order->markAsPaid();
expect($order->isPaid())->toBeTrue();Il ne dit rien sur la persistance. Un test d’intégration a besoin d’une commande de test non payée déjà enregistrée et d’une relecture qui ne réutilise pas le même objet :
$orderService->markAsPaid($orderId);
$entityManager->flush();
$entityManager->clear();
$reloadedOrder = $orderRepository->find($orderId);
expect($reloadedOrder?->isPaid())->toBeTrue();Ici, OrderService::markAsPaid($orderId) modifie une commande gérée, mais n’effectue ni flush ni clear et ne prend pas en charge la transaction. Le test fournit volontairement le flush. Si le vrai service est responsable du commit, testez ce contrat sans ajouter un flush redondant. Si cette responsabilité appartient au middleware, utilisez le bus ou reproduisez explicitement sa frontière.
Le repository, le service et le test doivent utiliser l’EntityManager et la base prévus. Clear empêche la réutilisation depuis l’Identity Map, mais ne désactive pas les caches de résultats ou de second niveau. Avec ces caches maîtrisés, la relecture peut vérifier l’état visible dans la transaction de test. Un test englobé dans une transaction annulée à la fin ne prouve pas la durabilité d’un commit de production. Cela exige un test approprié avec écriture validée et lecture indépendante de la base faisant autorité.
La répétition de l’exemple de commande détachée devient plus utile sous forme de test de régression que de nouveau listing sans observation supplémentaire. Partons à nouveau d’une commande non payée déjà enregistrée :
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();
$reloadedOrder = $orderRepository->find($orderId);
expect($order->isPaid())->toBeTrue()
->and($entityManager->contains($order))->toBeFalse()
->and($reloadedOrder?->isPaid())->toBeFalse();Les assertions distinguent l’état payé de l’ancien objet de l’état non payé relu dans une autre instance. Ce sont des extraits de tests de style Pest : préparation des données, isolation, mappings et configuration des services sont omis. Ils ne sont pas exécutables tels quels.
Des informations utiles en production
Journalisez des métadonnées choisies plutôt que des entités complètes : identifiant de corrélation, identifiant d’entité protégé de façon appropriée, type de message ou de commande, nombre de tentatives, événements clear/reset observés et résultat transactionnel. Temps SQL, mémoire du worker et informations sur les messages en échec sont utiles lorsque leur instrumentation est configurée. Le simple retour d’un gestionnaire ne prouve pas un commit réussi.
Évitez les secrets d’authentification, les données personnelles et les contenus sensibles des messages. Même les identifiants peuvent nécessiter un masquage ou des accès restreints. L’identité d’un objet aide à distinguer les références au sein d’un processus ; son identifiant PHP n’est toutefois pas un identifiant durable entre processus.
La première question est précise : quel EntityManager, s’il y en a un, gère cette instance maintenant ? Vérifiez ensuite si la modification est suivie et si sa transaction s’est terminée. Cette démarche distingue un changement en mémoire d’un résultat métier enregistré, sans ajouter des persist() spéculatifs ou multiplier les flush.
Documentation technique
- Doctrine ORM : cycle de vie des entités et Identity Map — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/working-with-objects.html
- Doctrine ORM : politiques de suivi des changements — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/change-tracking-policies.html
- Doctrine ORM : transactions et traitement des erreurs — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- Symfony 7.4 : cycle de vie des workers Messenger — https://symfony.com/doc/7.4/messenger.html
