Doctrine Events and Filters
Doctrine events let code react to what a flush is doing, and filters add SQL to every query the ORM builds.
#[AsDoctrineListener(event: Events::postFlush, priority: 0, connection: 'default')]Doctrine announces what it is doing. You can register a listener for those announcements and run your own code: index a document after it is saved, stamp a timestamp before an update, send a webhook once a write is safely committed.
The events, and where each one runs
Per entity there are prePersist, postPersist, preUpdate, postUpdate, preRemove, postRemove and postLoad. Per flush there are preFlush, onFlush and postFlush. The distinction that matters is the transaction. Everything except postFlush runs inside the transaction the flush opened, so work done there disappears if the commit fails, and slow work there holds database locks. postFlush runs after the commit, which makes it the right place for anything with an outside effect. In preUpdate you may only change values through $args->setNewValue($field, $value); assigning to the entity at that point is ignored.
Registering a listener
#[AsDoctrineListener(event: Events::postFlush)] on the class is the shorthand. The longhand is a service tagged doctrine.event_listener with the same event, priority and connection options in config/services.yaml. Either way the method is named after the event. In ORM 3 the event argument objects expose getObjectManager(); the old getEntityManager() was removed.
Filters
A filter appends a condition to every query Doctrine builds, which is how multi-tenancy is enforced in one place. Each enabled filter costs a fragment of SQL on every query and takes part in the query cache key, so keep the list short and the conditions indexed.
Example
<?php
// src/EventListener/OutboxDispatcher.php
declare(strict_types=1);
namespace App\EventListener;
use App\Service\DomainEventCollector;
use App\Service\WebhookSender;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::postFlush)]
final class OutboxDispatcher
{
public function __construct(
private readonly DomainEventCollector $collector,
private readonly WebhookSender $sender,
) {
}
/**
* postFlush runs once, after the transaction has committed, so anything
* sent from here corresponds to data that really is in the database.
*/
public function postFlush(PostFlushEventArgs $args): void
{
// Available if needed; in ORM 3 this is getObjectManager(), not
// the getEntityManager() that older code calls.
$entityManager = $args->getObjectManager();
foreach ($this->collector->takeAll() as $event) {
// No persist() or flush() in here: a flush inside postFlush is not safe.
$this->sender->send($event);
}
unset($entityManager);
}
}When to use it
- Send a webhook or queue a job only after the write has actually committed.
- Keep a search index in step with entities as they are saved.
- Scope every query in a multi-tenant application to the current tenant in one place.
More examples
The same listener registered in YAML, for two events
This is the longhand of #[AsDoctrineListener], with the same three options, and it is what you need when the listener class comes from a package you cannot add attributes to. It is also how one class subscribes to two events: collect the changed entities in postUpdate, which runs inside the transaction, then act on them in postFlush once the commit has happened.
# config/services.yaml
services:
App\EventListener\SearchIndexListener:
tags:
# One tag per event. The method must be named after the event,
# so this class needs postUpdate() and postFlush() methods.
- name: 'doctrine.event_listener'
event: 'postUpdate'
# higher priority runs earlier; the default is 0
priority: 500
- name: 'doctrine.event_listener'
event: 'postFlush'
# listeners apply to the default connection unless you say otherwise
connection: 'default'A tenant filter applied to every query
One class here replaces a tenant condition in every repository method, and the one method somebody forgets is the data leak this prevents. The constraint is cheap only if the column is indexed, because it now appears in every query against those tables, including the ones that load associations. Remember that a filter is SQL Doctrine adds: native SQL you write yourself, and entities already loaded in memory, are not affected by it.
<?php
// src/Doctrine/Filter/TenantFilter.php
declare(strict_types=1);
namespace App\Doctrine\Filter;
use App\Entity\TenantOwned;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!$targetEntity->reflClass?->implementsInterface(TenantOwned::class)) {
return '';
}
// getParameter() returns the value already quoted for this platform.
// Never concatenate the raw value into the fragment yourself.
return sprintf(
'%s.%s = %s',
$targetTableAlias,
$targetEntity->getColumnName('tenant'),
$this->getParameter('tenant_id'),
);
}
}
// Somewhere early in the request, before any query runs:
//
// $filter = $entityManager->getFilters()->enable('tenant');
// $filter->setParameter('tenant_id', $tenantId);
//
// Parameters must be set before the filter is used, never inside
// addFilterConstraint(), and the fragment must be deterministic:
// Doctrine includes the filter in the query cache key.
Discussion