Friday, 25 July 2025

Making sure this app also deletes from Elasticsearch when I delete an entity

G'day:

In yesterday's article - Integrating Elasticsearch into a Symfony app - I long-windedly showed how I integrated Elasticsearch into my test app to handle search indexing. I showed the "CRU" operations of "CRUD". And then admitted I forgot about deletes until I was writing the article, but otherwise left that notion there.

During the evening I decided I was being a lazy fuck, so went and added the deletion support as well. As it turns out it was predictably dead easy.

I have a SearchIndexer class that contains the config and event handlers to be triggered on create and update operations. All I needed to do is to add one for when deletions occur, and annotate things appropriately:

#[AsDoctrineListener(event: Events::preRemove, priority: 500, connection: 'default')]
class SearchIndexer
{
    // [...]

    public function preRemove(PreRemoveEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof SyncableToElasticsearch) {
            return;
        }

        $this->adapter->deleteDocument($entity->getElasticsearchId());
    }

That was it. If I deleted an entity: it was now removed from the Elasticsearch index too.

One observation here is that initially I tried with the postRemove event (note: "post" not "pre", like I have in the code). However for reasons best known to Doctrine, the PostRemoveEventArgs that the event handler receives doesn't include the entity's ID. Copilot reasoned that this was because the row that has that ID had already been removed from the DB, but that's ballocks (or is a bogus rationalisation, anyhow), as the ID belongs to the entity; the DB is just storage. One could also extend that analogy such that "well the Student with the name Jane Wootywoo doesn't exist in the DB any more either, but you seem to have no issue providing that information". Just because the storage tier doesn't need that ID any more, doesn't mean the entity or the application in general hasn't finished with it. But, anyway, the solution is to use the preRemove hook instead.

A second observation is that that getElasticsearchId method had previously been protected, but I saw no problem making it public for this usage.

I also updated the UI for students so I could perform a delete for testing, which necessitated this controller method:

// src/Controller/StudentController.php

#[Route('/students/{id}/delete', name: 'student_delete', requirements: ['id' => '\d+'])]
public function delete(Student $student, EntityManagerInterface $em): Response
{
    foreach ($student->getEnrolments() as $enrolment) {
        $em->remove($enrolment);
    }

    $em->remove($student);
    $em->flush();

    return $this->redirectToRoute('student_list');
}

I'd normally do this in a service as it's not the controller's job to know that Enrolments are related to Students so also need clearing up; but this is not an exercise in building an MVC app so I cut a corner here.

This controller method was the target for a "delete" link I put on the UI on the Student view page.

That's all I have to say about this. I'm quite pleased again with how easy it was to implement with Doctrine.

Righto.

--
Adam