Doctrine flush listener with flush in it

A project I work on has the requirement to fire a webhook every time the status of an entity - lets call it Transaction - has changed. To have the system still scalable, the webhook is not triggered directly, but added as its own entity into the database (and triggered later by a separate system).

As we want to trigger the webhook independent from the API endpoint, we use a Doctrine Listener for it. We use the onFlush event for this listener. That way we can't forget it in a new API endpoint or when we change another entity which is "only" related to it and changes the transaction indirectly.

Within our bootstrap file (or any other initialization process) we add the transaction listener:

$transactionListener = new TransactionListener();
$eventManager->addEventListener([Events::onFlush], $transactionListener);

Now to the problem. When creating (and storing) a new entity within a onFlush listener, we run into and infinite loop (because the webhook itself is flushed and the listener is triggered again).

So what do we do? I tried a few things. Like detaching and merging entities within the process. The thing that worked the best in the end, is also the simplest: Removing the listener at the beginning of the onFlush method and adding it back again at the end of it.

<?php

use Doctrine\ORM\Event\OnFlushEventArgs;  
use Doctrine\ORM\Events;  
use Doctrine\ORM\UnitOfWork;

/**
 * Class TransactionListener
 *
 * Doctrine listener for handling status changes
 */
class TransactionListener  
{
    /**
     * Add webhook calls for transaction status changes
     *
     * @param OnFlushEventArgs $eventArgs Event arguments
     */
    public function onFlush(OnFlushEventArgs $eventArgs)
    {
        // Preparation
        $em = $eventArgs->getEntityManager();

        // Remove listener
        $eventManager = $em->getEventManager();
        if($eventManager) {
            $eventManager->removeEventListener(
                [Events::onFlush], 
                $this
            );
        }

        // Get entities
        $uow = $em->getUnitOfWork();
        $entities = array_merge(
            $uow->getScheduledEntityInsertions(),
            $uow->getScheduledEntityUpdates()
        );

        $this->handleTransactions($uow, $entities);

        // Add listener back again
        if($eventManager) {
            $eventManager->addEventListener(
                [Events::onFlush], 
                $this
            );
        }
    }

    /**
     * Handle transactions
     *
     * @param UnitOfWork $uow Unit of work
     * @param array      $transactions Transactions
     */
    protected function handleTransactions(UnitOfWork $uow, array $transactions)
    {
        /** @var Transaction $transaction */
        foreach ($transactions as $transaction) {

            if (!$transaction instanceof Transaction) {
                continue;
            }

            $changeSet = $uow->getEntityChangeSet($transaction);

            $statusField = 'status';
            if (array_key_exists($statusField, $changeSet)) {

                $newValue = $changeSet[$statusField][1];

                $eventType = $this->getEventTypeForNewValue($newValue);

                if (!$eventType) {
                    return;
                }

                $this->createNewWebhook(
                    $eventType,
                    $transaction
                );
            }
        }
    }
    ...
}

The only thing you have to keep in mind now is, that you should not change the values your listener checks. As we use a separate listener for each entity and check and skip the entity when it's not the one we listen upon (!$transaction instanceof Transaction), we can be sure, that the temporary removal of our listener has no side effects.

comments powered by Disqus