Storing User Changes in a Symfony/Doctrine App

Admins on soccer league management site manyleagues.com need to be able to see who made which changes when. Those records are stored in a MySQL table and can be pulled up in a report, filterable by date, change type and so forth.

This post is about how to best capture the changes when a record is updated. Let’s use Game as an example, and suppose the location has been changed. We need a record that looks like this:
ChangeLogScreenshot

We have a listener to Doctrine’s onFlush event defined:

on_flush_listener:
    class: ManyLeagues\SoccerBundle\EventListener\OnFlushListener
    tags:
        - {name: doctrine.event_listener, event: onFlush}
namespace ManyLeagues\SoccerBundle\EventListener;

use Doctrine\ORM\Event\OnFlushEventArgs;

use ManyLeagues\SoccerBundle\Helper\ChangeLogHelper;

class OnFlushListener
{
    public function onFlush(OnFlushEventArgs $args)
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if (method_exists($entity, 'hasChangeLog') && $entity->hasChangeLog())
            {
                $changeset = $uow->getEntityChangeSet($entity);
                ChangeLogHelper::updatedEntity($entity, $changeset);
            }
        }
    }
}

Note that we test to see if the entity is one that we want to save. Not every class needs to have changes stored.

The ChangeLogHelper will get the description from the model and other parameters from the session:

public static function updatedEntity($entity, $changeset)
{
    ...
    $description = $entity->getChangesetDescription($changeset);
    self::recordChange('update', $entity, $description, $user_id, $ip, $site_id);
}

Entities share a trait that handles the descriptions:

public function getChangesetDescription($changeset)
{
    $description = [];
    foreach ($changeset as $field => $change)
    {
        $field_change_description = $this->getFieldChangeDescription($field, $change);
        if ($field_change_description)
        {
            $description[] = $field_change_description;
        }
    }
    return implode(';', $description);
}

getFieldChangeDescription is in the trait, but many entities override the method. In it’s basic form:

public function getFieldChangeDescription($field, $change)
{
    $ignore_fields = ['updatedAt'];
    if (!in_array($field, $ignore_fields))
    {
        $field_display = $this->getFieldDisplay($field);
        if ($field_display)
            return $this->getChangeDescriptionSentence($field_display, $change);
    }
    return NULL;
}

The two methods called here are easily customized in the model. In the trait, we have:

public function getChangeDescriptionSentence($field_display, $change)
{
    $change = $this->getFieldChangeDescriptionValues($field_display, $change);
    $old_val = $change[0];
    $new_val = $change[1];
    if ($old_val === $new_val)
    {
        return NULL;
    }
    return $field_display . ' changed from ' . $old_val . ' to ' . $new_val;
}

getFieldChangeDescriptionValues handles date formatting and empty values.

The main thing to keep in mind, Doctrine’s UnitOfWork has to be dealt with very precisely. Other Doctrine activity between the time of persistence and flushing can cause the changeset to be miscalculated, which not only interferes with the changelog records, but with the entity itself. For that reason ChangeLogHelper::recordChange creates a Redis hash with the necessary information, which will be queued for insertion into the changelog table.