Avec Doctrine ORM 3, une EntityNotFoundException dans un listener postRemove signifie presque toujours qu’on lit un proxy jamais chargé dont la ligne vient d’être supprimée. Dans un SaaS d’abonnements, la revalidation d’un abonnement modifié renvoyait une erreur 500. Le service supprimait l’ancien PDF du contrat d’abonnement, puis un listener postRemove lisait le chemin du fichier pour l’effacer du stockage S3 : Doctrine tentait alors de charger une ligne qui n’existait plus. Le correctif tient en une ligne, initializeObject() avant remove().
Un doublon de contrat, puis un fichier orphelin
Le point de départ est un autre bug. Quand un abonnement déjà validé était modifié puis revalidé, le générateur créait un nouveau PDF du contrat d’abonnement sans retirer l’ancien : deux documents « Contrat » dans la liste du client. Premier correctif, simple :
public function saveSubscriptionAsPdf(Subscription $subscription): Document
{
$previousContract = $subscription->getContract();
$file = $this->generatePdfFile($subscription);
$storedFileData = $this->saveOnStorage($subscription, $file);
$contract = $this->saveAsDocument($subscription, $storedFileData);
$this->attachContractDocumentToSubscription($subscription, $contract);
$this->removePreviousContract($previousContract);
return $contract;
}
private function removePreviousContract(?Document $previousContract): void
{
if ($previousContract === null) {
return;
}
$this->entityManager->remove($previousContract);
$this->entityManager->flush();
}
Le doublon disparaissait de la liste, mais remove() ne retire que la ligne document. Le PDF restait dans le stockage S3 (MinIO), orphelin. L’entité Document n’avait aucun preRemove ni postRemove, et le service de stockage n’exposait même pas de méthode delete(). La suppression manuelle d’un document via l’API laissait déjà des orphelins ; la revalidation en ajoutait un à chaque passage.
Le listener postRemove best-effort
J’ai ajouté FileStorageService::delete() et un entity listener sur postRemove, qui couvre tous les points de suppression d’un Document sans dupliquer de code :
#[AsEntityListener(event: Events::postRemove, entity: Document::class)]
readonly class DocumentListener
{
public function postRemove(Document $document, PostRemoveEventArgs $args): void
{
$filePathName = $document->getFilePathName();
if ($filePathName === null) {
return;
}
try {
$this->fileStorageService->delete($filePathName);
} catch (Throwable $e) {
$this->logger->error(sprintf('Cannot delete storage file "%s"', $filePathName), ['exception' => $e]);
}
}
}
postRemove plutôt que preRemove : le fichier n’est effacé qu’une fois la ligne supprimée en base. Best-effort : un échec côté stockage est loggé mais ne remonte pas, la suppression en base étant déjà faite. Pour les orphelins accumulés avant, une commande app:storage:clean-orphan-documents compare les fichiers du bucket aux file_path_name référencés en base, en dry-run par défaut et avec --force pour supprimer réellement.
Tests unitaires verts des deux côtés. Et pourtant, la revalidation d’un abonnement s’est mise à renvoyer une 500.
Pourquoi Doctrine lève EntityNotFoundException dans un postRemove ?
La relation contract est un OneToOne sans option fetch, donc paresseuse :
#[ORM\OneToOne(targetEntity: Document::class, cascade: ['persist', 'remove'])]
private ?Document $contract;
Un proxy Doctrine est un objet qui remplace l’entité tant qu’on ne l’a pas lue : il ne connaît que son identifiant et exécute le SELECT au premier accès à un champ. La séquence devient :
$subscription->getContract()renvoie un proxy non initialisé.remove()+flush(): Doctrine exécute leDELETEavec le seul identifiant.- Le listener appelle
getFilePathName(): le proxy s’initialise et lance leSELECT. - La ligne n’existe plus : Doctrine ORM 3.6.3 lève l’exception.
Le message est construit par EntityNotFoundException::fromClassNameAndIdentifier() ; avec un identifiant UUID, il prend cette forme :
Entity of type 'App\Entity\Document' for IDs id(0190a3b2-7c4e-7d10-9a1b-3f5e6d7c8b9a) was not found
Un listener
postRemovequi lit un champ suppose que l’entité est déjà en mémoire : sur un proxy, c’est une requête vers une ligne qui vient de disparaître.
Aucun des deux commits n’était faux isolément. Le premier supprimait un proxy sans jamais le lire, ce que Doctrine accepte. Le second lisait une entité supprimée, ce qui marche quand l’entité est chargée. Les tests unitaires mockaient l’EntityManager ou construisaient un Document avec new : jamais de proxy. C’est la même limite que dans le moteur de facturation qui oubliait persist() : un mock prouve qu’une méthode est appelée, pas ce que fait Doctrine.
Le correctif : initializeObject() avant remove()
private function removePreviousContract(?Document $previousContract): void
{
if ($previousContract === null) {
return;
}
// Hydrate le proxy tant que la ligne existe : le postRemove lit getFilePathName().
$this->entityManager->initializeObject($previousContract);
$this->entityManager->remove($previousContract);
$this->entityManager->flush();
}
initializeObject() exécute le SELECT tant que la ligne existe ; sur une entité déjà chargée, il ne relance pas de requête. Le test unitaire vérifie désormais l’appel à initializeObject() avant remove(), et son absence quand il n’y a pas de contrat précédent. Passer la relation en fetch: 'EAGER' aurait aussi marché, au prix d’une jointure à chaque chargement d’abonnement, pour un seul cas d’usage.
Lessons learned
- Un proxy Doctrine non initialisé ne survit pas à la suppression de sa ligne. Le premier getter après le
flush()déclenche unSELECTqui ne trouve rien et lèveEntityNotFoundException. - Un listener
postRemovedoit recevoir une entité déjà chargée. AppelerinitializeObject()avantremove()garantit que tous les champs sont en mémoire. - Un listener ajouté change le contrat de tous les
remove()existants. Le code qui supprimait un proxy sans le lire devient fautif dès qu’un listener lit un champ. - Les tests unitaires avec mocks ne voient pas les proxies. Seul un test d’intégration qui recharge l’entité depuis la base reproduit le chargement paresseux.
- Supprimer le fichier en
postRemove, en best-effort, et prévoir un rattrapage. Une commande en dry-run par défaut purge les orphelins accumulés sans toucher à la base.