Chapitre 6.5 — Cycle de vie & événements Doctrine
Partie 6 : Doctrine ORM — Chapitre 5/7 Version de référence : Doctrine ORM 3 / Symfony 7.4.
⏱️ TL;DR
- Une entité traverse un cycle de vie :
prePersist→postPersist,preUpdate→postUpdate,preRemove→postRemove,postLoad.- On y réagit via des lifecycle callbacks (méthodes de l’entité annotées, ex. remplir
updatedAt), des entity listeners (classes dédiées, injectables — préférable), ou des subscribers Doctrine.- Cas classique : timestamps (
createdAt/updatedAt) et slugs automatiques.- Distinction cruciale : les événements Doctrine se déclenchent pendant le flush (dans la transaction). Pour des effets métier (envoyer un e-mail), préférez un événement de domaine dispatché après le flush (Partie 5), pas un callback Doctrine.
- Pour un dev Next.js : ce sont les hooks/middleware Prisma (
$extends/prisma.$use), mais avec la même mise en garde : n’y mettez pas d’effets de bord lourds.
🎯 Objectifs
- Connaître les événements du cycle de vie d’une entité.
- Remplir automatiquement des champs (timestamps, slug).
- Choisir entre lifecycle callback, entity listener et subscriber.
- Savoir pourquoi les effets métier ne vont pas dans les événements Doctrine.
1. Le cycle de vie d’une entité
| Événement | Quand |
|---|---|
prePersist | avant l’INSERT d’une nouvelle entité |
postPersist | après l’INSERT (l’id est disponible) |
preUpdate | avant l’UPDATE d’une entité modifiée |
postUpdate | après l’UPDATE |
preRemove / postRemove | autour du DELETE |
postLoad | après le chargement depuis la base |
2. Lifecycle callbacks : le cas des timestamps
Le plus simple : des méthodes de l’entité déclenchées par ces événements. On active avec #[ORM\HasLifecycleCallbacks] et on annote les méthodes.
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Booking
{
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->createdAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}createdAt est fixé à l’insertion, updatedAt à chaque modification. Pratique et local à l’entité.
⚠️ Piège
preUpdate: cet événement est restreint. Vous ne pouvez pas y modifier librement des relations ni y appeler certaines opérations sur l’EntityManager(le changeset est déjà calculé).preUpdatesert à ajuster des champs scalaires de l’entité en cours de mise à jour, pas à déclencher de la logique complexe. Pour ça, voir §5.
3. Trait réutilisable pour les timestamps
Comme beaucoup d’entités veulent createdAt/updatedAt, on factorise dans un trait :
// src/Entity/Trait/TimestampableTrait.php
trait TimestampableTrait
{
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function initTimestamps(): void { $this->createdAt = new \DateTimeImmutable(); }
#[ORM\PreUpdate]
public function touchTimestamp(): void { $this->updatedAt = new \DateTimeImmutable(); }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
}Toute entité use TimestampableTrait; (avec #[ORM\HasLifecycleCallbacks]) obtient les timestamps. (Des bundles comme StofDoctrineExtensions offrent Timestampable/Sluggable clés en main — utiles, mais un trait maison suffit souvent.)
🐘 Rappel PHP 8+ : les traits (chapitre 2.5) permettent de partager des propriétés et des méthodes entre classes sans héritage. Ici, ils factorisent proprement un comportement transversal (timestamps) sur plusieurs entités.
4. Entity listeners : plus propre car injectable
Un lifecycle callback vit dans l’entité — il ne peut donc pas recevoir de services (une entité n’est pas un service). Dès qu’il faut une dépendance (un générateur de slug, un service externe), on utilise un entity listener : une classe dédiée et injectable.
use App\Entity\Provider;
use App\Service\Slugger;
use Doctrine\ORM\Event\PrePersistEventArgs;
final class ProviderSlugListener
{
public function __construct(private readonly Slugger $slugger) {} // injection !
public function prePersist(Provider $provider, PrePersistEventArgs $args): void
{
if ($provider->getSlug() === null) {
$provider->setSlug($this->slugger->slugify($provider->getName()));
}
}
}On l’attache à l’entité :
#[ORM\Entity]
#[ORM\EntityListeners([ProviderSlugListener::class])]
class Provider { /* ... */ }Avantage sur le callback : le listener reçoit des services et garde l’entité pure (pas de dépendance métier dans l’entité).
5. Le vrai piège : effets de bord dans les événements Doctrine
Grande tentation : « à postPersist d’un Booking, j’envoie l’e-mail de confirmation ». Mauvaise idée. Les événements Doctrine se déclenchent pendant le flush, à l’intérieur de la transaction :
- si l’e-mail échoue (SMTP down), il peut faire échouer la transaction (ou l’e-mail part alors que la transaction est ensuite annulée → e-mail « fantôme ») ;
- vous mélangez persistance et métier, deux préoccupations distinctes ;
- c’est difficile à tester et à raisonner.
La bonne approche (déjà vue en Partie 5) : le service métier persiste, puis dispatche un événement de domaine (BookingCreated) après le flush(). Les listeners de cet événement (ou un handler Messenger) envoient l’e-mail, hors de la transaction Doctrine.
// dans le service, PAS dans un événement Doctrine
$this->em->persist($booking);
$this->em->flush(); // transaction terminée
$this->dispatcher->dispatch(new BookingCreated($booking)); // puis le métier⚠️ Piège : réserver les événements Doctrine à ce qui concerne la donnée elle-même (timestamps, slug, calcul d’un champ dérivé). Tout effet métier (e-mail, appel externe, notification) passe par un événement de domaine dispatché après le flush, ou par Messenger (Partie 12). Ne mélangez pas persistance et métier.
💡 Pour un dev Next.js : c’est le même conseil qu’avec les middleware Prisma (
$use) : parfaits pour dériver un champ ou horodater, à proscrire pour envoyer des e-mails/appeler des API. Les effets de bord métier vivent après la transaction, dans la couche applicative.
✏️ Exercices
Exercice 1. Ajoutez des timestamps createdAt/updatedAt automatiques à l’entité Service. Quelle est l’annotation à ne pas oublier sur la classe ?
✅ Solution
Utiliser le TimestampableTrait (ou déclarer les deux champs + callbacks), et surtout annoter la classe avec #[ORM\HasLifecycleCallbacks] (sans quoi les méthodes #[ORM\PrePersist]/#[ORM\PreUpdate] ne sont jamais appelées) :
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Service
{
use TimestampableTrait;
// ...
}Exercice 2. Vous devez générer automatiquement le slug d’un Provider à partir de son name, via un service Slugger. Callback d’entité ou entity listener ? Pourquoi ?
✅ Solution
Un entity listener : le callback d’entité vit dans l’entité et ne peut pas recevoir le service Slugger (une entité n’est pas un service, pas d’injection). Un entity listener est une classe dédiée injectable qui reçoit Slugger par constructeur et remplit le slug en prePersist, tout en gardant l’entité pure.
Exercice 3. Un collègue envoie l’e-mail de confirmation dans un #[ORM\PostPersist] de Booking. Quels risques, et quelle est la bonne approche ?
✅ Solution
Risques : l’e-mail part pendant la transaction — s’il échoue, il peut casser le flush ; s’il réussit mais que la transaction est ensuite annulée, l’e-mail est un « fantôme » ; et on mélange persistance et métier (dur à tester). Bonne approche : le service persiste + flush(), puis dispatche un événement de domaine BookingCreated après le flush ; un listener (ou un handler Messenger) envoie l’e-mail hors de la transaction Doctrine.
🧠 Quiz de révision
1. Citez trois événements du cycle de vie d’une entité.
Au choix : prePersist, postPersist, preUpdate, postUpdate, preRemove, postRemove, postLoad.
2. Quelle annotation active les lifecycle callbacks sur une entité ?
#[ORM\HasLifecycleCallbacks] sur la classe (les méthodes portant #[ORM\PrePersist], etc.).
3. Pourquoi préférer un entity listener à un callback quand il faut un service ?
Parce qu’un callback vit dans l’entité (pas d’injection possible). Un entity listener est une classe injectable qui reçoit ses services et garde l’entité pure.
4. Peut-on envoyer un e-mail dans un postPersist ? Pourquoi ?
postPersist ? Pourquoi ?À éviter : les événements Doctrine se produisent pendant la transaction ; un e-mail y est risqué (échec, e-mail fantôme, mélange des responsabilités). On dispatche plutôt un événement de domaine après le flush (ou via Messenger).
5. À quoi réserver les événements Doctrine ?
À ce qui concerne la donnée elle-même (timestamps, slug, champ dérivé), pas aux effets métier (e-mails, appels externes).
Prochain chapitre : 6.6 — Performances — le problème N+1, les fetch joins, l’hydratation et les index.