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 addHasLifecycleCallbacks
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
{
//...
}
}
-
Lifecycle listeners
andlifecycle 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 overlifecycle 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();
}
}
-
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
{
//...
}
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:
#[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;
}
#[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;
}
#[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.
#[ORM\Entity]
#[ORM\EntityListeners([OrderListener::class])]
class Order
{
//...
}
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
{
//...
}
}
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 (oronFlush
, but this is not supported in entity listeners as this event does not belong to thelifecycle 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 thepostUpdate
method, we should useUnitOfWork
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 thepostRemove
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 aDateTime
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. theformat('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 theArrayCollection
class, but aPersistentCollection
. ThePersistentCollection
allows us to keep track of the changes made to that collection. It has its initial state stored in thesnapshot
property, whereas the current elements of the collection are located in thecollection
property. This class also has aisDirty
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']);
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();
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.
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
.
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();
In the preUpdate
method, the $args
parameter will have the following value:
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.
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);
In the preRemove
method, entity looks as follows:
And in the postRemove
method, the entity does not have the identifier:
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();
}
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();
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
:
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);
}
The result will be as follows:
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)
worst practice
interesting, can you elaborate on this?