Doctrine 2 - Log changes in manyToMany relation

Since i cannot add a comment to the accepted answer, i ll write here :)

Accepted solution will not work if you have multiple persists of your main entity in same flush. The last set of ManyToMany collection will be attached to all persisted entities. If you want to pick only the approriate you will have to check if the collection belongs to the processed object.

For example instead of

 // For each collection add it to the return array in our custom format.
foreach ($uow->getScheduledCollectionUpdates() as $col) {
    $associations = $this->getCollectionChangeSetData($col);
    $returnArray = array_merge($returnArray, $associations);
}   

you can use

// For each collection add it to the return array in our custom format.
$objectHash = spl_object_hash($object);
foreach ($uow->getScheduledCollectionUpdates() as $col) {
    $collectionOwner = $col->getOwner();
    if (spl_object_hash($collectionOwner) === $objectHash) {
        $associations = $this->getCollectionChangeSetData($col);
        $returnArray = array_merge($returnArray, $associations);
    }
}

Solution without creating your own join tables.

I have modified the LoggableListener that I created to override the Gedmo LoggableListener, my version works, play around with this till you get it working.

Basically, extend the Gedmo LoggableListener with your own version and override /add a few modified functions:

prePersistLogEntry is enabled to allow you to modify the logEntry if you want to. My logEntry entities contain a user entity and the users Full Name instead of their username.

getCollectionsChangeSetData is a new function to extract the collection and get access to the Doctrine PersistentCollections methods. [http://www.doctrine-project.org/api/orm/2.1/class-Doctrine.ORM.PersistentCollection.html]

stripCollectionArray new function to extract the desired information from the collection entities and insert them into a php array for persisting to the LogEntry.

For information, if you are planning to user the revert functionality of the Loggable doctrine extension then you will also need to extend and override the revert method in the LogEntryRepository. The current revert method will not recognise the id from the ManyToMany associations saved in the LogEntry. That is why stripCollectionArray function also saves the 'id' and 'class' values to the LogEntry.

Good Luck.

<?php

namespace AppBundle\Listener;

use Doctrine\Common\EventArgs;
use Gedmo\Loggable\Mapping\Event\LoggableAdapter;
use Gedmo\Tool\Wrapper\AbstractWrapper;
use Gedmo\Loggable\LoggableListener as GedmoLoggableListener;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use AppBundle\Entity\Clause;
use AppBundle\Entity\GuidanceNote;
use AppBundle\Entity\Standard;
use Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry;
use Doctrine\ORM\PersistentCollection;

/**
 * Loggable listener
 *
 * Extends the Gedmo loggable listener to provide some custom functionality.
 *
 *
 * @author Mark Ogilvie <[email protected]>
 */
class LoggableListener extends GedmoLoggableListener {

    // Token storage to get user
    private $tokenStorage;

    // Injet token storage in the services.yml
    public function __construct(TokenStorageInterface $token) {
        $this->tokenStorage = $token;
    }

    /**
     * Manipulate the LogEntry entity prior to persisting. 
     * In this case add a user, and set entity information
     * according to the custom entity family group.
     * 
     * @param EventArgs $eventArgs
     *
     * @return void
     */
    protected function prePersistLogEntry($logEntry, $object) {

        $user = $this->tokenStorage->getToken()->getUser();

        $logEntry instanceof AbstractLogEntry;

        $logEntry
                ->setUser($user)
                ->setChangedObject('text.default')
                ->setUsername($user->getFullName())
        ;

        switch (true) {
            case $object instanceof Clause:
                $logEntry->setChangedObject('text.clause')
                        ->setEntity($object)
                ;
                break;
            case $object instanceof GuidanceNote:
                $logEntry->setChangedObject('text.guidanceNote')
                        ->setEntity($object->getClause())
                ;
                break;
            case $object instanceof Standard:
                $logEntry->setChangedObject('text.standard')
                ;
                break;
        }
    }

    /**
     * Returns an objects changeset data
     * 
     * Modified to create an array which has old and new values instead
     * of just the new.
     * 
     * Also added reference to UoW collection changes to pick up ManyToMany
     * relationships
     *
     * @param LoggableAdapter $ea
     * @param object $object
     * @param object $logEntry
     *
     * @return array
     */
    protected function getObjectChangeSetData($ea, $object, $logEntry) {
        $om = $ea->getObjectManager();
        $wrapped = AbstractWrapper::wrap($object, $om);
        $meta = $wrapped->getMetadata();
        $config = $this->getConfiguration($om, $meta->name);
        $uow = $om->getUnitOfWork();

        // Define an array to return as the change set data.
        $returnArray = array();

        foreach ($ea->getObjectChangeSet($uow, $object) as $field => $changes) {
            if (empty($config['versioned']) || !in_array($field, $config['versioned'])) {
                continue;
            }

            $value = $changes[1];
            if ($meta->isSingleValuedAssociation($field) && $value) {
                if ($wrapped->isEmbeddedAssociation($field)) {
                    $value = $this->getObjectChangeSetData($ea, $value, $logEntry);
                } else {
                    $oid = spl_object_hash($value);
                    $wrappedAssoc = AbstractWrapper::wrap($value, $om);
                    $value = $wrappedAssoc->getIdentifier(false);
                    if (!is_array($value) && !$value) {
                        $this->pendingRelatedObjects[$oid][] = array(
                            'log' => $logEntry,
                            'field' => $field,
                        );
                    }
                }
            }

            $returnArray[$field]['previous'] = $changes[0];
            $returnArray[$field]['new'] = $value;
        }

        // For each collection add it to the return array in our custom format.
        foreach ($uow->getScheduledCollectionUpdates() as $col) {
            $associations = $this->getCollectionChangeSetData($col);
            $returnArray = array_merge($returnArray, $associations);
        }   

        return $returnArray;
    }

    /**
     * New custom function to get information about changes to entity relationships
     * Use the PersistentCollection methods to extract the info you want.
     * 
     * @param PersistentCollection $col
     * @return array
     */
    private function getCollectionChangeSetData(PersistentCollection $col) {

        $fieldName = $col->getMapping()['fieldName'];

        // http://www.doctrine-project.org/api/orm/2.1/class-Doctrine.ORM.PersistentCollection.html
        // $col->toArray() returns the onFlush array of collection items;
        // $col->getSnapshot() returns the prePersist array of collection items
        // $col->getDeleteDiff() returns the deleted items
        // $col->getInsertDiff() returns the inserted items
        // These methods return persistentcollections. You need to process them to get just the title/name
        // of the entity you want.
        // Instead of creating two records, you can create an array of added and removed fields.
        // Use private a newfunction stripCollectionArray to process the entity into the array

        $newValues[$fieldName]['new'] = $this->stripCollectionArray($col->toArray());
        $newValues[$fieldName]['previous'] = $this->stripCollectionArray($col->getSnapshot());

        return $newValues;
    }

    /**
     * Function to process your entity into the desired format for inserting
     * into the LogEntry
     * 
     * @param type $entityArray
     * @return type
     */
    private function stripCollectionArray($entityArray) {
        $returnArr = [];
        foreach ($entityArray as $entity) {
            $arr = [];
            $arr['id'] = $entity->getId();
            $arr['class'] = get_class($entity);

            if (method_exists($entity, 'getName')) {
                $arr['name'] = $entity->getName();
            } elseif (method_exists($entity, 'getTitle')) {
                $arr['name'] = $entity->getTitle();
            } else {
                $arr['name'] = get_class($entity);
            }
            $returnArr[] = $arr;
        }


        return $returnArr;
    }

}