How To Build Activity Log Using Doctrine Events
Tags: php, doctrine, symfony
Published: 2022-12-20 22:54:42

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.
  ```php
  #[ORM\Entity]
  #[ORM\HasLifecycleCallbacks]
  class Order
  {
      #[ORM\PrePersist]
      public function doSmth(): void
      {
          //...
      }
      
      #[ORM\PrePersist]
      public function doSmth2(): void
      {
          //...
      }
  }  
  ```

- `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: 

  ```php
    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();
        }
    }
  ```

- `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:
  ```php
    #[ORM\Entity]
    #[ORM\EntityListeners([OrderListener::class])]
    class Order
    {
        //...   
    }
  ```
  ```php
    use App\Entity\Order;
    use Doctrine\Persistence\Event\PreUpdateEventArgs;
    
    class OrderListener
    {
        public function preUpdate(Order $order, PreUpdateEventArgs $event): void
        {
            //do smth
        }
    }  
  ```
  
## 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:

```php
#[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;
}
```

```php
#[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;
}
```

```php
#[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;
}
```

## 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.

```php
#[ORM\Entity]
#[ORM\EntityListeners([OrderListener::class])]
class Order
{
    //...
}
```

The listener class looks like this:

```php
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
    {
        //...
    }
}
```

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`.

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

$statusNew = $statusRepository->findOneBy(['name' => 'New']);
$statusCollecting = $statusRepository->findOneBy(['name' => 'Collecting']);
```

## Adding the order

We add the order in `New` status with a smartphone:

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

$this->entityManager->persist($order);
$this->entityManager->flush();
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/94dviw5cyiqj1se181r0.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rat0xwig525rryds00g0.png)

# 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.

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

$this->entityManager->flush();
```

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


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/reoep1qs6tz0bhz03txn.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6d5g4go1gii99j0xtsvz.png)

## Deleting the Order

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

```php
$this->entityManager->remove($order);
$this->entityManager->flush($order);
```

In the `preRemove` method, entity looks as follows:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lxpau01jp28dcgo76ci3.png)

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


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hghel3phmd7mrhlweyzx.png)

## 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:

```php
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();
 }
```

Let's create three orders:

```php
$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();
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oe4y9yv5599pqjqg9rqk.png)

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

```php
foreach ($scheduledUpdated as $entity) {
    $unitOfWork->getEntityChangeSet($entity);
}
```

The result will be as follows:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yeph4sxepftsx2b9ffiw.png)

## 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.