Is there a way to force a doctrine event ( like preUpdate ) on a parent associated entity ?
So for example: I have a order
entity with one-to-many orderItem
entities.
Now, I want to do a bunch of checkup's and possible changes to the order
entity or even one of it's orderItem
entities ( where I need to access many other services) whenever any of the orderItems
change. But the doctrine events do not fire on the order
entity when one of its orderItem
entities changes.
Note: this post entirely focuses on the particular case of the preUpdate
event. It is possible to dispatch an event manually by using the event manager. The problem lies in the fact that simply triggering the preUpdate
event of an entity is not enough to have its new state persisted to the database if the preUpdate
method modified something.
There are multiple ways to do this but none of them are really straightforward. Considering only the case of the preUpdate
event, I had quite a lot of trouble to find how to do this in a clean way as association updates are simply not built in a way to handle such cases as discussed in the Doctrine documentation.
Either way, if you want to do this, among the solutions I found, there were many that suggested to directly mess up with the UnitOfWork
of Doctrine. This can be quite powerful but then you have to be careful about what you use and when you use it as Doctrine might not be able to actually dispatch the event you want in some cases discussed below.
Anyway, I ended up implementing something that makes use of a change of tracking policy for the parent entity. By doing so, the parent entity preUpdate
event can be triggered if one of its properties is modified or if one of its "children" was modified.
If you wish to use the UnitOfWork
(that you can retrieve by using $args->getEntityManager()->getUnitOfWork()
with any type of arguments of lifecycle events), you can use the public method scheduleForUpdate(object $entity)
. However, if you wish to use this method, you will need to call it before the commit order is computed inside of the unit of work. Moreover, if you have a preUpdate event associated to the entity you scheduled for update, it will raise an error if your entity has an empty change set (which is exactly the case we are dealing with when the main entity is not modified but one of its related entities is).
Thus calling $unitOfWork->scheduleForUpdate($myParentEntity)
, in a preUpdate
of a child entity is not an option as explained in the documentation (performing calls to the UnitOfWork API is strongly discouraged as it does not work as it would outside of the flush operation).
It should be noted that $unitOfWork->scheduleExtraUpdate($parentEntity, array $changeset)
can be used in that specific context but this method is marked as "INTERNAL". The following solutions avoid using it but it might be a good approach if you know what you are getting into.
Note: I did not test the implementation of the wanted behaviour with the onFlush event but it was often presented as the most powerful approach. For the other two possibilities listed here, I tried them successfully with a OneToMany association.
In the following section, when I'm talking about a parent entity, I refer to the entity that has the OneToMany association while children entities are refering to the entities that have the ManyToOne association (thus, the children entities are the owning side of the association).
You can try to work your way out of this by using the onFlush event however, in that case you have to deal with the UnitOfWork internals as suggested in the documentation. In that case, you can't do it within an Entity listener (introduced in 2.4) as the onFlush event is not among the possible callbacks. Some examples based on what's given by the official doc can be found on the web. Here is a possible implementation: Update associated entities in doctrine.
The main drawback here is that you don't really trigger the preUpdate
event of your entity, you just handle the behaviour you wanted somewhere else. It seemed a bit too heavy handed for me, so I searched for other solutions.
One way to actually trigger the preUpdate
event of the parent entity, is to add another entity listener to the child entity and to use the UnitOfWork. As explained before, you can't simply do this in the preUpdate
event of the child entity.
In order for the commit order to be properly computed, we need to call scheduleForUpdate
and propertyChanged
in the preFlush
event of the child entity listener as shown below:
class ChildListener
{
public function preFlush(Child $child, PreFlushEventArgs $args)
{
$uow = $args->getEntityManager()->getUnitOfWork();
// Add an entry to the change set of the parent so that the PreUpdateEventArgs can be constructed without errors
$uow->propertyChanged($child->getParent(), 'children', 0, 1);
// Schedule for update the parent entity so that the preUpdate event can be triggered
$uow->scheduleForUpdate($child->getParent());
}
}
As you can see, we need to notify the UnitOfWork that a property has changed so that everything works properly. It looks a bit sloppy but it gets the work done.
The important part is that we mark the children
property (the OneToMany association of the parent) as changed so that the change set of the parent entity is not empty. A few important notes about the internals at stake with this propertyChanged
call:
children
works here.preUpdate
event.The main problem of this approach is that the parent entity is scheduled for update even if it is not needed. As there is no direct way to tell if the child entity has changed in its preFlush
event (you could use the UnitOfWork but it would become a bit redundant with its internals), you will trigger the preUpdate event of the parent at every flush where a child entity is being managed.
Moreover, with this solution, Doctrine will begin a transaction and commit even if there are no queries performed (e.g. if nothing was modified at all, you will still find in the Symfony Profiler, two consecutives entries "START TRANSACTION" and "COMMIT" in the Doctrine logs).
Since I've been messing with the internals of the UnitOfWork quite a bit, I stumbled upon the propertyChanged
method (that was used in the previous solution) and noticed that it was part of the interface PropertyChangedListener
. It happens that this is linked to a documented topic: the tracking policy. By default, you can just let Doctrine detect the changes but you can also change this policy and manage everything manually as explained here, in the documentation.
After reading about this, I eventually came up with the following solution that cleanly handles the wanted behaviour, the cost being that you have to do some extra work in your entities.
Thus, to have exactly what I desired, my parent entity follows the NOTIFY
tracking policy and children notify the parent when one of their properties is modified. As described in the official documentation, you have to implement the NotifyPropertyChanged
interface and then notify the listeners of properties changes (the UnitOfWork
automatically adds itself to the listeners if it detects that one of the managed entities implements the interface). After that, if the annotation @ChangeTrackingPolicy is added, at commit times, Doctrine will rely on the change set that was built via propertyChanged
calls and not on an automatic detection.
Here is how you would do it for a basic Parent entity:
namespace AppBundle\Entity;
use Doctrine\Common\NotifyPropertyChanged;
use Doctrine\Common\PropertyChangedListener;
/**
* ... other annotations ...
* @ORM\EntityListeners({"AppBundle\Listener\ParentListener"})
* @ORM\ChangeTrackingPolicy("NOTIFY")
*/
class Parent implements NotifyPropertyChanged
{
// Add the implementation satisfying the NotifyPropertyChanged interface
use \AppBundle\Doctrine\Traits\NotifyPropertyChangedTrait;
/* ... other properties ... */
/**
* @ORM\Column(name="basic_property", type="string")
*/
private $basicProperty;
/**
* @ORM\OneToMany(targetEntity="AppBundle\Entity\Child", mappedBy="parent", cascade={"persist", "remove"})
*/
private $children;
/**
* @ORM\Column(name="other_field", type="string")
*/
private $otherField;
public function __construct()
{
$this->children = new \Doctrine\Common\Collections\ArrayCollection();
}
public function notifyChildChanged()
{
$this->onPropertyChanged('children', 0, 1);
}
public function setBasicProperty($value)
{
if($this->basicProperty != $value)
{
$this->onPropertyChanged('basicProperty', $this->basicProperty, $value);
$this->basicProperty = $value;
}
}
public function addChild(Child $child)
{
$this->notifyChildChanged();
$this->children[] = $child;
$child->setParent($this);
return $this;
}
public function removeChild(Child $child)
{
$this->notifyChildChanged();
$this->children->removeElement($child);
}
/* ... other methods ... */
}
with the trait taken from the code given in the documentation:
namespace AppBundle\Doctrine\Traits;
use Doctrine\Common\PropertyChangedListener;
trait NotifyPropertyChangedTrait
{
private $listeners = [];
public function addPropertyChangedListener(PropertyChangedListener $listener)
{
$this->listeners[] = $listener;
}
/** Notifies listeners of a change. */
private function onPropertyChanged($propName, $oldValue, $newValue)
{
if ($this->listeners)
{
foreach ($this->listeners as $listener)
{
$listener->propertyChanged($this, $propName, $oldValue, $newValue);
}
}
}
}
and the following Child entity with the owning side of the association:
namespace AppBundle\Entity;
class Child
{
/* .. other properties .. */
/**
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\Parent", inversedBy="children")
*/
private $parentEntity;
/**
* @ORM\Column(name="attribute", type="string")
*/
private $attribute;
public function setAttribute($attribute)
{
// Check if the parentEntity is not null to handle the case where the child entity is created before being attached to its parent
if($this->attribute != $attribute && $this->parentEntity)
{
$this->parentEntity->notifyChildChanged();
$this->attribute = $attribute;
}
}
/* ... other methods ... */
}
And there it is, you have everything fully working. If, your child entity is modified, you explicitly call notifyChildChanged
that will then notify the UnitOfWork
that children
field has changed for the parent entity thus cleanly triggering the update process and the preUpdate
event if one is specified.
Unlike the solution #2, the event will be triggered only if something has changed and you can control with precision why it should be marked as changed. For example, you could mark the children as changed if only a certain set of attributes is changed and ignore other changes as you have full control other what is eventually notified to the UnitOfWork
.
Note:
preFlush
events won't be triggered in the Parent entity listener (preFlush event being triggered in computeChangeSet which is simply not called for entities using this policy).children
entry in the change set as it will be simply ignored when the update query is created since the parent entity is NOT the owning side of the association. (i.e. it does not have any foreign keys)In my application, I added the following trait
namespace AppBundle\Utils\Traits;
trait MagicSettersTrait
{
/** Returns an array with the names of properties for which magic setters can be used */
abstract protected function getMagicSetters();
/** Override if needed in the class using this trait to perform actions before set operations */
private function _preSetCallback($property, $newValue) {}
/** Override if needed in the class using this trait to perform actions after set operations */
private function _postSetCallback($property, $newValue) {}
/** Returns true if the method name starts by "set" */
private function isSetterMethodCall($name)
{
return substr($name, 0, 3) == 'set';
}
/** Can be overriden by the class using this trait to allow other magic calls */
public function __call($name, array $args)
{
$this->handleSetterMethodCall($name, $args);
}
/**
* @param string $name Name of the method being called
* @param array $args Arguments passed to the method
* @throws BadMethodCallException if the setter is not handled or if the number of arguments is not 1
*/
private function handleSetterMethodCall($name, array $args)
{
$property = lcfirst(substr($name, 3));
if(!$this->isSetterMethodCall($name) || !in_array($property, $this->getMagicSetters()))
{
throw new \BadMethodCallException('Undefined method ' . $name . ' for class ' . get_class($this));
}
if(count($args) != 1)
{
throw new \BadMethodCallException('Method ' . $name . ' expects 1 argument (' . count($args) . ' given)');;
}
$this->_preSetCallback($property, $args[0]);
$this->$property = $args[0];
$this->_postSetCallback($property, $args[0]);
}
}
which I could then use in my entities. Here is an example of my Tag entity whose preUpdate
event needed to be called when one of its aliases was modified:
/**
* @ORM\Table(name="tag")
* @ORM\EntityListeners({"AppBundle\Listener\Tag\TagListener"})
* @ORM\ChangeTrackingPolicy("NOTIFY")
*/
class Tag implements NotifyPropertyChanged
{
use \AppBundle\Doctrine\Traits\NotifyPropertyChangedTrait;
use \AppBundle\Utils\Traits\MagicSettersTrait;
/* ... attributes ... */
protected function getMagicSetters() { return ['slug', 'reviewed', 'translations']; }
/** Called before the actuel set operation in the magic setters */
public function _preSetCallback($property, $newValue)
{
if($this->$property != $newValue)
{
$this->onPropertyChanged($property, $this->$property, $newValue);
}
}
public function notifyAliasChanged()
{
$this->onPropertyChanged('aliases', 0, 1);
}
/* ... methods ... */
public function addAlias(\AppBundle\Entity\Tag\TagAlias $alias)
{
$this->notifyAliasChanged();
$this->aliases[] = $alias;
$alias->setTag($this);
return $this;
}
public function removeAlias(\AppBundle\Entity\Tag\TagAlias $alias)
{
$this->notifyAliasChanged();
$this->aliases->removeElement($alias);
}
}
I can then reuse the same trait in my "child" entity named TagAlias:
class TagAlias
{
use \AppBundle\Utils\Traits\MagicSettersTrait;
/* ... attributes ... */
public function getMagicSetters() { return ['alias', 'main', 'locale']; }
/** Called before the actuel set operation in the magic setters */
protected function _preSetCallback($property, $newValue)
{
if($this->$property != $newValue && $this->tag)
{
$this->tag->notifyAliasChanged();
}
}
/* ... methods ... */
}
Note: If you chose to do this, you might encounter errors when Forms are trying to hydrate your entities as magic calls are disabled by default. Simply add the following to your services.yml
to enable magic calls. (taken from this discussion)
property_accessor:
class: %property_accessor.class%
arguments: [true]
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With