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:
- Identify the Doctrine entities to index
- Automatically index/unindex Doctrine entities based on their lifecycle
- 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:
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:
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.
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:
- Use a CompilerPass, but it won't work simply because our entities are not registered in the Symfony Container (and fortunately so).
- 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. - 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:
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:
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:
- Remove the
RegisterIndexationListenerPass
- Transform the
IndexationListener
as follows:
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}