Doctrine Events Custom Behavior

Hacking through Doctrine Events...

Before starting, you need to know that this guide is written for Symfony 2.6.3 but it can be easily used for previous versions as well, with a few adjustments.

As developers, we spend most of our time creating ways in which to interact with data. There are times when this is trivial and times when this is not. In order to minimize repetition, we reuse code that usually is packaged as Symfony bundles.

An example where this works out great is when we have to use stuff like timestamps, softdelete, slugs, and so on. The Gedmo DoctrineExtensions provides this functionality out of the box. The tricky part, is customizing these behaviors. Some bundles and libraries allow you to use their events in order to customize their functionality, just like the Gedmo DoctrineEvents does.

In this guide, we will have a quick look at what to do when a bundle allows you to customize certain actions and what to do when it doesn't.

Let's take the following scenario: a super simple blog with 2 entities, Post and PostStatus tied via a ManyToOne-OneToMany bidirectional relationship. We would like to know when a post was created, updated or deleted, but we do not want to actually remove the post from the database, instead, we want to mark it with a status called "Deleted" and let it stay in the trash, until, one day, we will add a method to take out the trash. Since we already know that the softdeleteable default behavior from Gedmo is based on the deletedAt column, we would like this to be set to null when a post is changed from "Deleted" to something else.

With these requirements in mind, we create the 2 entities Post and PostStatus.

A very common practice is to have constants on classes for quickly referencing the objects that represent the state of data and that's why we have the following inside out PostStatus class:

class PostStatus
{
    const DRAFT = 1;
    const PUBLISHED = 2;
    const DELETED = 3;
}

Having these constants here allows us to quickly grab reference objects instead of querying the database.

The next step is configuring listeners as services as described in the Gedmo docs so inside of services.yml we add these:

    # add under the services key in services.yml
    gedmo.listener.timestampable:
        class: Gedmo\Timestampable\TimestampableListener
        tags:
            - { name: doctrine.event_subscriber, connection: default }
        calls:
            - [ setAnnotationReader, [ @annotation_reader ] ]

    gedmo.listener.softdeletable:
        class: Gedmo\SoftDeleteable\SoftDeleteableListener
        tags:
            - { name: doctrine.event_subscriber, connection: default }
        calls:
            - [ setAnnotationReader, [ @annotation_reader ] ]

These 2 services will handle the default timestampable and softdeleteable functionality, but, in order for everything to work properly, our Post class must be configured properly by adding the right traits and annotations to it.

The annotation that we're looking for here is Gedmo\Mapping\Annotation\SoftDeleteable and we will be configuring it exactly like the gedmo docs say that we should. We will be creating a Gedmo alias for the Gedmo\Mapping\Annotation namespace and then we simply add the annotation to our class.

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\SoftDeleteable\Traits\SoftDeleteableEntity;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Gedmo\Mapping\Annotation as Gedmo;

/**
 * Post
 *
 * @ORM\Table(name="post")
 * @ORM\Entity
 * @Gedmo\SoftDeleteable(fieldName="deletedAt", timeAware=false)
 */
class Post
{
    use TimestampableEntity;
    use SoftDeleteableEntity;
}

By using the Gedmo\SoftDeleteable\Traits\SoftDeleteableEntity and Gedmo\Timestampable\Traits\TimestampableEntity traits in our class, we also get the required database columns when we create our database. These 2 traits have the database columns configured with everything that they need for the behavior to work and they also have the proper annotations set.

So far, in a very small number of steps we have added 2 important features without writing any actual logic. Now that this is out of the way, it's time to write some custom logic.

Some people prefer using their own manager services and triggering the events that they want from inside certain actions. There are situations where that is indeed the preferred method to do things, unless it leads to repetition. Ideally you should keep your code DRY. We use the event listener method because we simply want to enforce our behavior everywhere and we don't have to worry about writing custom solutions to 3rd parties.

By going through the Doctrine and Gedmo docs we know the events that we're looking for are onFlush event from Doctrine and the preSoftDelete from Gedmo.

When a bundle or library provides a way to customize it's functionality, that should be the preferred way to do it, otherwise, use the appropriate Doctrine listener.

    # add under the services key in services.yml
    app_bundle.event_listener.post:
        class: %app_bundle.event_listener.post.class%
        tags:
            - { name: doctrine.event_listener, event: onFlush, connection: default }
            - { name: doctrine.event_listener, event: preSoftDelete, connection: default }
    # add under the parameters key in services.yml
    app_bundle.event_listener.post.class: AppBundle\EventListener\PostListener

We then create the PostListener to handle all our custom logic:

namespace AppBundle\EventListener;

use AppBundle\Entity\Post;
use AppBundle\Entity\PostStatus;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\OnFlushEventArgs;

class PostListener
{
    public function onFlush(OnFlushEventArgs $event)
    {
        $entityManager = $event->getEntityManager();
        $unitOfWork = $entityManager->getUnitOfWork();

        foreach ($unitOfWork->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof Post) {
                if (!$entity->getPostStatus()) {
                    $entity->setPostStatus(
                        $entityManager->getReference('AppBundle:PostStatus', PostStatus::DRAFT)
                    );
                    $unitOfWork->recomputeSingleEntityChangeSet(
                        $entityManager->getClassMetadata('AppBundle:Post'),
                        $entity
                    );
                }
            }
        }

        foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
            if ($entity instanceof Post) {
                $changeSet = $unitOfWork->getEntityChangeSet($entity);
                if (isset($changeSet['postStatus'])) {
                    if (!$entity->getPostStatus()) {
                        $entity->setPostStatus(
                            $entityManager->getReference('AppBundle:PostStatus', PostStatus::DRAFT)
                        );
                    }
                    if ($entity->getDeletedAt() && $entity->getPostStatus()->getId() !== PostStatus::DELETED) {
                        $entity->setDeletedAt(null);
                    }
                    $unitOfWork->recomputeSingleEntityChangeSet(
                        $entityManager->getClassMetadata('AppBundle:Post'),
                        $entity
                    );
                }
            }
        }
    }

    public function preSoftDelete(LifecycleEventArgs $event)
    {
        $entityManager = $event->getEntityManager();
        $unitOfWork = $entityManager->getUnitOfWork();

        $entity = $event->getEntity();
        if ($entity instanceof Post) {
            $currentStatus = $entity->getPostStatus();
            $newStatus = $entityManager->getReference('AppBundle:PostStatus', PostStatus::DELETED);
            $unitOfWork->propertyChanged($entity, 'postStatus', $currentStatus, $newStatus);
            $unitOfWork->scheduleExtraUpdate($entity, array(
                'postStatus' => array($currentStatus, $newStatus),
            ));
        }
    }
}

That's a lot of code, so let's break it down into more useful information. We have a PostListener class that is now configured as a service, that listens to events dispatched by Doctrine. This class has only 2 methods, 1 for each event it listens too, onFlush (standard doctrine event) and preSoftDelete (a custom event, defined in the Gedmo extension).

Inside of our onFlush method, we will be looping through the entities that are going to be inserted or updated, in order to make our changes. We will first handle the inserts, as that would be the logical way, data processing starts when you first create it.

        $entityManager = $event->getEntityManager();
        $unitOfWork = $entityManager->getUnitOfWork();

        foreach ($unitOfWork->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof Post) {
                if (!$entity->getPostStatus()) {
                    $entity->setPostStatus(
                        $entityManager->getReference('AppBundle:PostStatus', PostStatus::DRAFT)
                    );
                    $unitOfWork->recomputeSingleEntityChangeSet(
                        $entityManager->getClassMetadata('AppBundle:Post'),
                        $entity
                    );
                }
            }
        }

The unit of work API from Doctrine (more info can be found here) is what we are using in order to manipulate the data to fit our needs. In our first loop, we are checking for entities that are instances of the Post class and we will set the status to "Draft" when the status isn't set. You might argue that a better solution to achieve this, is to have the form field for the post status required and validate the presence of the status, but no. We are using this, because it future-proofs all our forms and any eventually APIs for data consistency. Also, this way is dynamic and therefore can be further customized based on the current users roles or anything else you might want to do, for example.

Remember those constants that we defined on the PostStatus? We are using them to create reference objects to pass setters in order to avoid querying the database during the flush event.

        foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
            if ($entity instanceof Post) {
                $changeSet = $unitOfWork->getEntityChangeSet($entity);
                if (isset($changeSet['postStatus'])) {
                    if (!$entity->getPostStatus()) {
                        $entity->setPostStatus(
                            $entityManager->getReference('AppBundle:PostStatus', PostStatus::DRAFT)
                        );
                    }
                    if ($entity->getDeletedAt() && $entity->getPostStatus()->getId() !== PostStatus::DELETED) {
                        $entity->setDeletedAt(null);
                    }
                    $unitOfWork->recomputeSingleEntityChangeSet(
                        $entityManager->getClassMetadata('AppBundle:Post'),
                        $entity
                    );
                }
            }
        }

When it comes to the entities that are scheduled for updates, we check for instances of the Post class and if there are any changes for the postStatus property. If there changes, they are in the form of an array with 2 elements where the first element is the old value and the seconds one is the new value. If the new value is null, we revert back to Draft, in order to be consistent with what we did in the insertion loop. Since the value that was set for the postStatus before persisting the object and flushing the changes to the database is the new value and what we are interested in, we simply use the getters and setters and not the data from $changeSet['postStatus’].

The common part of these 2 loops is the recomputeSingleEntityChangeSet call. Because Doctrine is unaware of changes that happen during the onFlush event, we must ask it manually to compute the changeset after making a change.

The preSoftDelete event is different, as it's triggered for each each entity inside the SoftDeleteableListener's own onFlush method. In order to keep consistent with the code from the SoftDeleteableListener, we use the propertyChanged and scheduleExtraUpdate methods from the unit of work API to let Doctrine know that the value has changed.

Please note that the preSoftDelete event is triggered inside an onFlush event for objects that are scheduled to be deleted, so we basically are doing the same thing in this event as we're doing inside of one of the loops from onFlush.

Another thing to note is that you should never ever, under any circumstances, call persist() and flush() on the entity manager during an event as this can lead to unpredictable behavior since you are at this point already inside an onFlush event.

With all these in mind, we're now ready to tackle even more advanced stuff.

Let us know what you think!

Want more awesome stuff?

Check us out on our Medium Remote Symfony Team publication.