Lifecycle Callbacks and Listeners

Run code when Doctrine saves an entity, with prePersist and preUpdate, and know what is unsafe inside them.

Syntax#[ORM\HasLifecycleCallbacks] on the class, then #[ORM\PreUpdate] public function touch(PreUpdateEventArgs $args): void

A lifecycle callback is a method Doctrine calls at a point in an entity's persistence: before the INSERT, before the UPDATE, after loading. The usual job is a timestamp, so that no caller has to remember to set one. The class must carry #[ORM\HasLifecycleCallbacks]; without it the callback attributes are read by nobody and nothing happens.

The two you will use

#[ORM\PrePersist] runs when persist() is called on a new entity, which is the right place for a createdAt that should never change. #[ORM\PreUpdate] runs during flush(), just before the UPDATE, and only if Doctrine computed a non-empty changeset for that entity. Doctrine passes an event argument object such as PrePersistEventArgs or PreUpdateEventArgs; a callback that declares no parameter simply ignores it, which is why you will see both signatures.

Entity listeners

A callback on the entity has no services: the entity is constructed by you or hydrated by Doctrine, never injected. When the work needs a dependency, such as a clock or a logger, move it to a listener class marked #[AsEntityListener(event: Events::prePersist, entity: Comment::class)]. That class is a service, so the constructor can take anything, and it is instantiated only when the event fires.

What is unsafe in there

Do not call flush(); you are inside one, and the recursion is immediate. Avoid persist() and remove() in preUpdate. Changes to associations made in prePersist are not picked up. In preUpdate the changeset is already computed, so a field is changed with $args->setNewValue('field', $value), not by assigning the property.

Example

Example · php
<?php
// src/Entity/Comment.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
// Without this the callbacks below are never called.
#[ORM\HasLifecycleCallbacks]
class Comment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(type: Types::TEXT)]
    private string $body;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    public function __construct(string $body)
    {
        $this->body = $body;
    }

    // Called by persist(), before the INSERT.
    #[ORM\PrePersist]
    public function initialiseTimestamps(): void
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // Called during flush(), and only when this entity has real changes.
    // Doctrine passes PreUpdateEventArgs; the shorthand is to leave the
    // parameter out, and the longhand is to declare it:
    //   public function touch(): void
    //   public function touch(PreUpdateEventArgs $args): void
    #[ORM\PreUpdate]
    public function touch(PreUpdateEventArgs $args): void
    {
        if ($args->hasChangedField('body')) {
            $this->updatedAt = new \DateTimeImmutable();
        }
    }

    public function edit(string $body): void
    {
        $this->body = $body;
    }
}

When to use it

  • Set createdAt once, at insert, without trusting every caller to pass it.
  • Write an audit line whenever an order's status column changes, with the old and new value.
  • Compute a derived column, such as a search string, just before the row is written.

More examples

The same timestamp, in a listener with a clock

The same behaviour as the callback, with the time coming from an injected clock instead of new DateTimeImmutable() inside the entity. That makes the entity testable: a test can set the clock to a fixed instant and assert the stored value. Reach for a listener whenever the callback would need a service.

Example · php
<?php
// src/Doctrine/CommentTimestampListener.php

declare(strict_types=1);

namespace App\Doctrine;

use App\Entity\Comment;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\Clock\ClockInterface;

// Registered for one entity and one event. method is optional: without it,
// Doctrine looks for a method named after the event, then for __invoke().
#[AsEntityListener(event: Events::prePersist, entity: Comment::class, method: 'prePersist')]
final class CommentTimestampListener
{
    // A service, so dependencies arrive through the constructor. The entity
    // itself could never have this.
    public function __construct(private readonly ClockInterface $clock)
    {
    }

    // Entity listeners receive the entity first, then the event arguments.
    public function prePersist(Comment $comment, PrePersistEventArgs $args): void
    {
        $comment->initialiseCreatedAt($this->clock->now());
    }
}

Reacting to a field change

preUpdate is the only place that can see the old and the new value of a field, which is what makes it useful for auditing. It is also the most constrained: the changeset is fixed, so setNewValue is the way in, and a new entity created here would not be saved by the flush that is already running.

Example · php
<?php
// src/Doctrine/OrderStatusAuditListener.php

declare(strict_types=1);

namespace App\Doctrine;

use App\Entity\Order;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Psr\Log\LoggerInterface;

#[AsEntityListener(event: Events::preUpdate, entity: Order::class, method: 'preUpdate')]
final class OrderStatusAuditListener
{
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function preUpdate(Order $order, PreUpdateEventArgs $args): void
    {
        // Nothing to do unless this particular field moved.
        if (!$args->hasChangedField('status')) {
            return;
        }

        $this->logger->info('Order status changed', [
            'reference' => $order->getReference(),
            'from' => $args->getOldValue('status'),
            'to' => $args->getNewValue('status'),
        ]);

        // To change a field from here, go through the changeset. Assigning the
        // property directly would be too late: the UPDATE is already planned.
        // setNewValue only accepts a field Doctrine already put in the
        // changeset; on any other field it throws InvalidArgumentException.
        if ($args->hasChangedField('statusChangedBy')) {
            $args->setNewValue('statusChangedBy', 'system');
        }

        // No persist(), no remove(), no flush() in here.
    }
}

Listeners declared on the entity

#[ORM\EntityListeners] lists the classes that listen for this entity's events; the listener class then holds the event methods. It is useful when you want the entity file to disclose that something happens around it. The trade-off is that the registration is now in two places, so a project should settle on either this or #[AsEntityListener].

Example · php
<?php
// src/Entity/Document.php

declare(strict_types=1);

namespace App\Entity;

use App\Doctrine\DocumentChecksumListener;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
// The other direction: the entity names its listener classes instead of each
// listener naming the entity. Same effect; pick one style per project.
#[ORM\EntityListeners([DocumentChecksumListener::class])]
class Document
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 64)]
    private string $checksum = '';

    #[ORM\Column(length: 255)]
    private string $path;

    public function __construct(string $path)
    {
        $this->path = $path;
    }

    public function getPath(): string
    {
        return $this->path;
    }

    public function applyChecksum(string $checksum): void
    {
        $this->checksum = $checksum;
    }
}

Discussion

  • Be the first to comment on this lesson.