Listen to Doctrine Events on Entities Using a PHP Attribute


A Bit of Context...

At Wamiz, we started working on a proof of concept (POC) to implement Meilisearch to offer our users a search engine, topic suggestions, etc.

Meilisearch needs to be fed with data from our database and updated based on changes to our Doctrine entities. Therefore, we need to:

  1. Identify the Doctrine entities to index
  2. Automatically index/unindex Doctrine entities based on their lifecycle
  3. As a bonus, handle the initial indexing of our existing Doctrine entities

Having previously used the AlgoliaSearchBundle, I had experience with these issues, and I knew they could be partially solved with a configuration like this:

config/packages/algolia_search.yaml
1algolia_search:
2  indices:
3    - name: posts
4      class: App\Entity\Post
5      index_if: isPublished

It's easy to understand, allows centralized declaration of entities to index, and provides control over which entities to listen to. However:

  • It uses YAML 😞
  • There is no auto-completion or validation
  • I want the configuration to be in the code of our Doctrine entities (as close to the code as possible), not in a configuration file
  • It's 2023, and I finally want to write a PHP attribute! 👀

With a PHP attribute named IndexableEntity that we will create later, we can do something like this:

src/Entity/Post.php
 1<?php
 2
 3// ...
 4
 5#[ORM\Entity]
 6#[IndexableEntity(
 7    index: 'posts',
 8    indexIf: 'isPublished',
 9    // Used for initial indexing, not covered in this blog post
10    initialDataCriteria: [PostRepository::class, 'createMeilisearchIndexableCriteria'],
11)]
12class Post
13{
14    // ...
15}

And this would easily allow us to:

  • Declare entities to index in a decentralized manner
  • Have auto-completion and configuration validation (thanks to PHPStan)

Creating the PHP Attribute

Our PHP attribute will:

  • Define the Meilisearch index in which the entity will be indexed
  • Define a method to call to check if the entity is indexable
  • Define a callback to retrieve the initial data to index (e.g., if we don't want to index entities created more than N years ago)

To create a PHP attribute, we need to create an annotated class with the #[Attribute] attribute.

src/Meilisearch/Attribute/IndexableEntity.php
 1<?php
 2
 3declare(strict_types=1);
 4
 5namespace App\Meilisearch\Attribute;
 6
 7#[\Attribute(\Attribute::TARGET_CLASS)]
 8final class IndexableEntity
 9{
10    /**
11     * @param string        $index               A Meilisearch index where the entity will be indexed
12     * @param string|null   $indexIf             A method name to call to check if the entity is indexable (if null, the entity is always indexable)
13     * @param callable|null $initialDataCriteria A callback to retrieve the initial data to index
14     */
15    public function __construct(
16        public string $index,
17        public string|null $indexIf = null,
18        public mixed $initialDataCriteria = null,
19    ) {
20        if (!is_callable($initialDataCriteria)) {
21            throw new \InvalidArgumentException('The initial data criteria must be a callable.');
22        }
23    }
24}

Listening to Changes on Doctrine Entities with the IndexableEntity Attribute

There are several solutions to listen to changes on Doctrine entities with the IndexableEntity attribute:

  1. Use a CompilerPass, but it won't work simply because our entities are not registered in the Symfony Container (and fortunately so).
  2. Use a Doctrine Lifecycle Listener. This is a solution I didn't choose because I didn't want to use a listener that would listen to all Doctrine events on all entities and have to filter entities with the IndexableEntity attribute.
  3. Use a Doctrine Entity Listener to listen the loadClassMetadata, postPersist/postUpdate/preRemove events on Doctrine entities.

I chose the 3rd solution, as it seems cleaner and more performant (although I haven't done Blackfire traces). However, with more hindsight, I think the 2nd solution would have been better in terms of maintainability and understanding.

So, our IndexationListener will looks like this:

src/Meilisearch/EventListener/IndexationListener.php
 1<?php
 2
 3declare(strict_types=1);
 4
 5namespace App\Meilisearch\EventListener;
 6
 7use App\Meilisearch\IndexationHelper;
 8use Doctrine\ORM\EntityManagerInterface;
 9
10#[AsDoctrineListener(Events::loadClassMetadata)]
11final class IndexationListener
12{
13    /**
14     * @var list<class-string>
15     */
16    private array $listenedEntities = [];
17
18    public function loadClassMetadata(LoadClassMetadataEventArgs $args): void
19    {
20        $metadata = $args->getClassMetadata();
21
22        // We only want to listen to entities once
23        if (\in_array($metadata->getName(), $this->listenedEntities, true)) {
24            return;
25        }
26
27        if ([] === $metadata->getReflectionClass()->getAttributes(IndexableEntity::class)) {
28            return;
29        }
30
31        $metadata->addEntityListener('postPersist', self::class, 'postPersist');
32        $metadata->addEntityListener('postUpdate', self::class, 'postUpdate');
33        $metadata->addEntityListener('preRemove', self::class, 'preRemove');
34
35        $this->listenedEntities[] = $metadata->getName();
36    }
37
38    public function postPersist(object $indexableEntity): void
39    {
40        $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity);
41
42        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to index the entity
43    }
44
45    public function postUpdate(object $indexableEntity): void
46    {
47        $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity);
48
49        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to index the entity
50    }
51
52    public function preRemove(object $indexableEntity): void
53    {
54        $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity);
55
56        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to remove the entity
57    }
58}

The method IndexationHelper::getAttribute will be used to retrieve the instance of the IndexableEntity attribute from the entity, but this detail will not be covered in this article.

Configuring the EntityListenerServiceResolver of Doctrine

The final step with the 2nd solution is to configure the Doctrine EntityListenerServiceResolver to inject our IndexationListener present in the Symfony Container instead of letting Doctrine handle it (because if our IndexationListener depends on services, it will be messed up).

For this, we can create the Symfony CompilerPass:

src/Meilisearch/DependencyInjection/Compiler/RegisterIndexationListenerPass.php
 1<?php
 2
 3declare(strict_types=1);
 4
 5namespace App\Meilisearch\DependencyInjection\Compiler;
 6
 7use App\Meilisearch\EventListener\IndexationListener;
 8use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
 9use Symfony\Component\DependencyInjection\ContainerBuilder;
10use Symfony\Component\DependencyInjection\Reference;
11
12final class RegisterIndexationListenerPass implements CompilerPassInterface
13{
14    public function process(ContainerBuilder $container)
15    {
16        $resolver = $container->getDefinition('doctrine.orm.default_entity_listener_resolver');
17        $resolver->addMethodCall(
18            'register',
19            [new Reference(IndexationListener::class)],
20        );
21    }
22}

Usage

Thanks to the use of Doctrine listeners, there are no additional modifications to be made in the code; persist(), flush(), and remove() work as usual:

1<?php
2
3$post = new Post(
4    title: 'My post',
5);
6$entityManager->persist($post);
7$entityManager->flush();
8
9// The method `IndexationListener::postPersist()` will be called

Going Further

Migrating to a Doctrine Lifecycle Listener

As mentioned earlier, this is the solution I should have chosen, as it is simpler to understand and maintain, but probably less performant.

This will require the following modifications:

  1. Remove the RegisterIndexationListenerPass
  2. Transform the IndexationListener as follows:
src/Meilisearch/EventListener/IndexationListener.php
 1<?php
 2
 3declare(strict_types=1);
 4
 5namespace App\Meilisearch\EventListener;
 6
 7use App\Meilisearch\IndexationHelper;
 8use Doctrine\ORM\EntityManagerInterface;
 9
10#[AsDoctrineListener(event: Events::postPersist, priority: 500, connection: 'default')]
11#[AsDoctrineListener(event: Events::postUpdate, priority: 500, connection: 'default')]
12#[AsDoctrineListener(event: Events::preRemove, priority: 500, connection: 'default')]
13final class IndexationListener
14{
15    public function postPersist(PostPersistEventArgs $args): void
16    {
17        $entity = $args->getObject();
18
19        if (null === $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity)) {
20            return;
21        }
22
23        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to index the entity
24    }
25
26    public function postUpdate(PostUpdateEventArgs $args): void
27    {
28        $entity = $args->getObject();
29
30        if (null === $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity)) {
31            return;
32        }
33
34        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to index the entity
35    }
36
37    public function preRemove(PreRemoveEventArgs $args): void
38    {
39        $entity = $args->getObject();
40
41        if (null === $indexableEntityAttribute = IndexationHelper::getAttribute($indexableEntity)) {
42            return;
43        }
44
45        // TODO: do something with the attribute and $indexableEntity, ex: dispatch a Messenger message to index the entity
46    }
47}