DEV Community

Jarosław Szutkowski
Jarosław Szutkowski

Posted on • Edited on

How To Build Activity Log Using Doctrine Events

Recently I've been working on a feature for tracking user activity in the application based on Symfony and Doctrine ORM. In Symfony, there are already several tools for this purpose, such as sonata-project/entity-audit-bundle, which creates a twin table to the table where entity data is stored. This is certainly a convenient solution for simple change tracking. However, it is not very customisable.
In my case, I had to record what changed in the particular orders. I had to present only changes made to some fields, not the entire entities.
I decided to create my own solution based on how Doctrine manages entities. This ORM offers listeners and subscribers which are being triggered when we persist data. Using them is pretty straightforward and allows to create more customisable solutions.

How it works in Doctrine

Entities can have various types of fields. From primitive types, dates, to various types of relationships. When an operation is made on an entity and $entityManager->flush() is being called, by default, Doctrine goes through all entities which are currently managed in the unit of work, and looks what has changed. During this process, various events are being triggered, which we can listen to. These are:

  • Lifecycle callbacks - these are public methods in the entity class which are called when a particular events occur. To make these methods to be called, we have to add HasLifecycleCallbacks attribute before the class declaration. Next, we have to mark particular methods with the proper attributes, which specifies the event we want to listen to. What's important, we can declare several methods to handle the same events.
  #[ORM\Entity]
  #[ORM\HasLifecycleCallbacks]
  class Order
  {
      #[ORM\PrePersist]
      public function doSmth(): void
      {
          //...
      }

      #[ORM\PrePersist]
      public function doSmth2(): void
      {
          //...
      }
  }  
Enter fullscreen mode Exit fullscreen mode
  • Lifecycle listeners and lifecycle subscribers are separate classes with methods which handle particular events. They are called when all entities are being saved. This allows us to create interactions between saved objects in them. Furthermore, the undoubted advantage over lifecycle callbacks is that other services can be called in them. Due to the fact that they are called on all entities, their performance is lower, compared to the previous solution. The difference between lifecycle listeners and subscribers is that subscribers must implement method which defines which events we subscribe to, whereas listeners must have these events defined in the service configuration file. An example event subscriber looks like below:
    use Doctrine\Common\EventSubscriber;
    use Doctrine\ORM\Event\OnFlushEventArgs;
    use Doctrine\ORM\Event\PreUpdateEventArgs;
    use Doctrine\ORM\Events;

    class EntitySubscriber implements EventSubscriber
    {
        public function getSubscribedEvents(): array
        {
            return [
                Events::preUpdate,
                Events::onFlush,
            ];
        }

        public function preUpdate(PreUpdateEventArgs $args): void
        {
            $args->getEntity();
            $args->getEntityChangeSet();
        }

        public function onFlush(OnFlushEventArgs $args): void
        {
            $entityManager = $args->getEntityManager();
            $unitOfWork = $entityManager->getUnitOfWork();

            $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions();
            $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates();
            $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions();

            $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates();
            $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions();
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • Entity listeners - they are similar to lifecycle listeners, the only difference is that they are called for entities of a specific class. It makes them more efficient compared to the above solution. An example listener might look like this:
    #[ORM\Entity]
    #[ORM\EntityListeners([OrderListener::class])]
    class Order
    {
        //...   
    }
Enter fullscreen mode Exit fullscreen mode
    use App\Entity\Order;
    use Doctrine\Persistence\Event\PreUpdateEventArgs;

    class OrderListener
    {
        public function preUpdate(Order $order, PreUpdateEventArgs $event): void
        {
            //do smth
        }
    }  
Enter fullscreen mode Exit fullscreen mode

Events

Above, I described how we can listen to particular events. It would be also good to know which events we can listen to. There are quite a few of them. Some are only available in lifecycle listeners and lifecycle subscribers though. Those include:

  • prePersist - occurs before persisting changes to the database, when the persist method is called on the entity manager.
  • preRemove -occurs before deleting an entity, when the remove method is called on the entity manager.
  • preUpdate - occurs before an entity is updated.
  • preFlush - occurs at the beginning of the flush operation.
  • onFlush - at this point the changes made to all managed entities are recalculated. We have access to all entities that will be added, modified or deleted. We can also access modified collections. This event does not belong to the lifecycle callback.
  • postFlush - occurs after the changes have been persisted. This event does not belong to the lifecycle callback.
  • postUpdate, postRemove, postPersist - occur similarly, after persisting changes to the database.
  • postLoad - occurs after an entity has been loaded from the database

Let's code

In this section I will show you what you can find in each method when listening to events. I will use XDebug for this purpose.

Preparing entities

For the purposes of this blog post, I have prepared three entities: Order, Status and Product. I want to track changes to the Order entity. I have added some fields to it which are: a primitive type, a date, a relationship to another entity and an entity collection. It looks as follows:

#[ORM\Entity]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'integer', nullable: false)]
    private int $totalPrice;

    #[ORM\Column(type: 'date', nullable: true)]
    private ?\DateTimeImmutable $deliveryDate;

    #[ORM\ManyToOne(targetEntity: Status::class)]
    #[ORM\JoinColumn(name: 'status_id', referencedColumnName: 'id')]
    private Status $status;

    #[ORM\ManyToMany(targetEntity: Product::class)]
    #[ORM\JoinTable(name: 'order_products')]
    #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')]
    #[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id')]
    private Collection $products;
}
Enter fullscreen mode Exit fullscreen mode
#[ORM\Entity]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'string', nullable: false)]
    private string $name;

    #[ORM\Column(type: 'integer', nullable: false)]
    private int $price;
}
Enter fullscreen mode Exit fullscreen mode
#[ORM\Entity]
class Status
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'string', nullable: false)]
    private string $name;
}
Enter fullscreen mode Exit fullscreen mode

Tracking changes

When changing, adding, deleting an order, or modifying some of its fields, appropriate events are being triggered. I will use an entity listener to listen to them. To do it, I need to add the attribute in the Order class, so that it will assign the appropriate listener to this entity.

#[ORM\Entity]
#[ORM\EntityListeners([OrderListener::class])]
class Order
{
    //...
}
Enter fullscreen mode Exit fullscreen mode

The listener class looks like this:

use App\Entity\Order;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;

class OrderListener
{
    public function postPersist(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }

    public function preUpdate(Order $order, PreUpdateEventArgs $args): void
    {
        $changeSet = $args->getEntityChangeSet();
    }

    public function postUpdate(Order $order, LifecycleEventArgs $args): void
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();

        $changeSet = $uow->getEntityChangeSet($order);
    }

    public function preRemove(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }

    public function postRemove(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }
}
Enter fullscreen mode Exit fullscreen mode

There are three things you can do with an order: add, modify or delete.

  • When adding a new order, I can find the data I am interested about by listening to the postPersist event. Added order will have no changes, but only its original state. In my tracking feature I can, for example, record when the order was created and with which products.
  • When modifying an order, access to the changed fields will be available in the method listening to the preUpdate event (or onFlush, but this is not supported in entity listeners as this event does not belong to the lifecycle callback). What's important, $args->getEntityChangeSet() will not return information about modified collections - to check what changed in them, we need do it in a different way, but more about that later. In case we would like to extract the changes in the postUpdate method, we should use UnitOfWork for that, as shown in the above example.
  • If we need to delete an order, and we need its identifier, then it is good to correlate the identifier of this entity with the entity itself in any way in the preRemove method. Why? In the postRemove method, the entity will no longer have an identifier.

When listening to changes in entities, there are two types of changes to look out for. Changes in dates and relationships.

  • After modifying date fields, which are of date type, in database we store only the date, without the time (or time is set to 00:00:00). When modifying such a field, we often set a DateTime object in there with the time. Then, even if the date is the same, Doctrine will still detect a change on that field, because the time is different. It is worth to use, e.g. the format('Y-m-d') method to check if the date has changed.

  • Collections in an entity are expressed through the Collection interface. When creating an entity, such a collection is usually initialised with an object of ArrayCollection class. This is the class which stores all the elements of the collection, and allows various operations to be performed on them, such as adding/removing elements, filtering, etc. When an entity is persisted or retrieved from a database, the collection is no longer an instance of the ArrayCollection class, but a PersistentCollection. The PersistentCollection allows us to keep track of the changes made to that collection. It has its initial state stored in the snapshot property, whereas the current elements of the collection are located in the collection property. This class also has a isDirty method which tells us if anything in the collection has changed. It is good to use it not to unnecessarily lazy load the entire collection if it has not been modified before.

Placing an order

Let's suppose we have a product catalogue which includes a smartphone and a TV. And we want to change the order's status from New to Collecting.

$productSmartphone = $productRepository->findOneBy(['name' => 'Smartphone']); //1000
$productTv = $productRepository->findOneBy(['name' => 'Tv']); //1500

$statusNew = $statusRepository->findOneBy(['name' => 'New']);
$statusCollecting = $statusRepository->findOneBy(['name' => 'Collecting']);
Enter fullscreen mode Exit fullscreen mode

Adding the order

We add the order in New status with a smartphone:

$order = new Order();
$order
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order);
$this->entityManager->flush();
Enter fullscreen mode Exit fullscreen mode

After calling the persist method on the entityManager, the prePersist method from the event listener is called. As you can see in the screenshot below, the entity does not yet have an identifier.

Image description

After the flush method is called, the postPersist method in the listener is called. The order object will have an identifier as it has already been stored in the database. The class representing the collection has changed from ArrayCollection to PersistentCollection.

Image description

Modifying the order

Let's add another product to the order, change its status, recalculate the price and set a different delivery date. If we retrieved such an object from the database, it would have a date with the time 00:00:00. Here, we provide the date with the current time and this will cause Doctrine to detect the change in this field as well.

$order
    ->addProduct($productTv)
    ->setStatus($statusCollecting)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->calculatePrice();

$this->entityManager->flush();
Enter fullscreen mode Exit fullscreen mode

In the preUpdate method, the $args parameter will have the following value:

Image description

However, you cannot see changes made to the collection here. You can get to them by browsing the collection itself - $order->getProducts() - in both the preUpdate and postUpdate methods.

Image description

Deleting the Order

Finally, we will delete the placed order. To do this, you need to call the code below:

$this->entityManager->remove($order);
$this->entityManager->flush($order);
Enter fullscreen mode Exit fullscreen mode

In the preRemove method, entity looks as follows:

Image description

And in the postRemove method, the entity does not have the identifier:

Image description

Another way

Additionally, I will show how we could read those changes not in the Entity Listener, but in the Event Subscriber. As I mentioned above, the Event Subscriber also handles additional events, including onFlush. Inside it, we have access to all entities which are affected by any changes. We can access them by calling the following methods:

public function onFlush(OnFlushEventArgs $args): void
 {
     $entityManager = $args->getEntityManager();
     $unitOfWork = $entityManager->getUnitOfWork();

     $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions();
     $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates();
     $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions();

     $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates();
     $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions();
 }
Enter fullscreen mode Exit fullscreen mode

Let's create three orders:

$order1 = new Order();
$order1
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order1);
$this->entityManager->flush();

$order2 = new Order();
$order2
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order2);
$this->entityManager->flush();

$order3 = new Order();
$order3
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order3);

$this->entityManager->remove($order1);

$order2
    ->addProduct($productTv)
    ->calculatePrice();

$this->entityManager->flush();
Enter fullscreen mode Exit fullscreen mode

The code above looks quite complicated. Let's focus on what is going to happen after the last flush method call - we will delete $order1, add a new product to $order2 with price recalculation, and $order3 will be written to the database for the first time. We can extract interesting data from UnitOfWork:

Image description

Additionally, if we want to preview changes which affected orders (in this case on $order2), we can reiterate the $scheduledUpdated array.

foreach ($scheduledUpdated as $entity) {
    $unitOfWork->getEntityChangeSet($entity);
}
Enter fullscreen mode Exit fullscreen mode

The result will be as follows:

Image description

Summary

Doctrine provides a simple way to access information about what has changed in an entity. Our task is to create the appropriate listeners and listen to appropriate events. What we do with the data is up to us. We can put them on a queue and process them in another process by persisting them in some event log created for this purpose. One thing I would avoid is direct flushing in these listeners, as this can cause at least falling into an infinite loop or recalculating changes multiple times which is not optimal.

Top comments (2)

Collapse
 
andrzej_ddbc2f4ab4ed4a9b6 profile image
Andrzej

worst practice

Collapse
 
jszutkowski profile image
Jarosław Szutkowski

interesting, can you elaborate on this?