Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Event Hooks

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

There's one other major way to hook into things with EasyAdminBundle... and it's my favorite! Go back to the base AdminController and search for "event". You'll see a lot in here! Whenever EasyAdminBundle does, well, pretty much anything... it dispatches an event: PRE_UPDATE, POST_UPDATE, POST_EDIT, PRE_SHOW, POST_SHOW... yes we get the idea already!

And this means that we can use standard Symfony event subscribers to totally kick EasyAdminBundle's butt!

Creating an Event Subscriber

Create a new Event directory... though, this could live anywhere. Then, how about, EasyAdminSubscriber. Event subscribers always implement EventSubscriberInterface:

... lines 1 - 2
namespace AppBundle\Event;
... lines 4 - 5
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
... lines 7 - 8
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 11 - 21
}

I'll go to the "Code"->"Generate" menu - or Command+N on a Mac - and choose "Implement Methods" to add the one required method: getSubscribedEvents():

... lines 1 - 8
class EasyAdminSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
... lines 13 - 15
}
... lines 17 - 21
}

EasyAdminBundle dispatches a lot of events... but fortunately, they all live as constants on a helper class called EasyAdminEvents. We want to use PRE_UPDATE. Set that to execute a new method onPreUpdate that we will create in a minute:

... lines 1 - 4
use JavierEguiluz\Bundle\EasyAdminBundle\Event\EasyAdminEvents;
... lines 6 - 8
class EasyAdminSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
EasyAdminEvents::PRE_UPDATE => 'onPreUpdate',
];
}
... lines 17 - 21
}

But first, I'll hold Command and click into that class. Dude, this is cool: this puts all of the possible hook points right in front of us. There are a few different categories: most events are either for customizing the actions and views or for hooking into the entity saving process.

That difference is important, because our subscriber method will be passed slightly different information based on which event it's listening to.

Back in our subscriber, we need to create onPreUpdate(). That's easy, but it's Friday and I'm so lazy. So I'll hit the Alt+Enter shortcut and choose "Create Method":

... lines 1 - 6
use Symfony\Component\EventDispatcher\GenericEvent;
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 11 - 17
public function onPreUpdate(GenericEvent $event)
{
... line 20
}
}

Thank you PhpStorm Symfony plugin!

Notice that it added a GenericEvent argument. In EasyAdminBundle, every event passes you this same object... just with different data. So, you kind of need to dump it to see what you have access to:

... lines 1 - 8
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 11 - 17
public function onPreUpdate(GenericEvent $event)
{
dump($event);die;
}
}

Since we're using Symfony 3.3 and the new service configuration, my event subscriber will automatically be loaded as a service and tagged as an event subscriber:

... lines 1 - 5
services:
# default configuration for services in *this* file
_defaults:
autowire: true
autoconfigure: true
public: false
AppBundle\:
resource: '../../src/AppBundle/*'
exclude: '../../src/AppBundle/{Entity,Repository,Tests}'
AppBundle\Controller\:
resource: '../../src/AppBundle/Controller'
public: true
tags: ['controller.service_arguments']
... lines 21 - 32

If that just blew your mind, check out our Symfony 3.3 series!

This means we can just... try it! Edit a user and submit. Bam!

Fetching Info off the Event

For this event, the important thing is that we have a subject property on GenericEvent... which holds the User object. We can get this via $event->getSubject():

... lines 1 - 10
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 13 - 26
public function onPreUpdate(GenericEvent $event)
{
$entity = $event->getSubject();
... lines 30 - 38
}
}

Remember though, this PRE_UPDATE event will be fired for every entity - not just User. So, we need to check for that: if $entity instanceof User, then we know it's safe to work our magic:

... lines 1 - 10
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 13 - 26
public function onPreUpdate(GenericEvent $event)
{
$entity = $event->getSubject();
if ($entity instanceof User) {
... lines 32 - 37
}
}
}

Since we already took care of setting the updatedAt in the controller, let's do something different. The User class also has a lastUpdatedBy field, which should be a User object:

... lines 1 - 16
class User implements UserInterface
{
... lines 19 - 87
/**
* @ORM\OneToOne(targetEntity="User")
* @ORM\JoinColumn(name="last_updated_by_id", referencedColumnName="id", nullable=true)
*/
private $lastUpdatedBy;
... lines 93 - 279
}

Let's set that here.

That means we need to get the currently-logged-in User object. To get that from inside a service, we need to use another service. At the top, add a constructor. Then, type-hint the first argument with TokenStorageInterface. Watch out: there are two of them... and oof, it's impossible to know which is which. Choose either of them for now. Then, name the argument and hit Alt+Enter to create and set a new property:

... lines 1 - 8
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class EasyAdminSubscriber implements EventSubscriberInterface
{
private $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
... lines 19 - 39
}

Back on top... this is not the right use statement. I'll re-add TokenStorageInterface: make sure you choose the one from Security\Core\Authentication:

... lines 1 - 8
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 13 - 39
}

In our method, fetch the user with $user = $this->tokenStorage->getToken()->getUser(). And if the User is not an instanceof our User class, that means the user isn't actually logged in. In that case, set $user = null:

... lines 1 - 10
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 13 - 26
public function onPreUpdate(GenericEvent $event)
{
$entity = $event->getSubject();
if ($entity instanceof User) {
$user = $this->tokenStorage->getToken()->getUser();
if (!$user instanceof User) {
$user = null;
}
... lines 36 - 37
}
}
}

Then, $entity->setLastUpdatedBy($user):

... lines 1 - 10
class EasyAdminSubscriber implements EventSubscriberInterface
{
... lines 13 - 26
public function onPreUpdate(GenericEvent $event)
{
$entity = $event->getSubject();
if ($entity instanceof User) {
$user = $this->tokenStorage->getToken()->getUser();
if (!$user instanceof User) {
$user = null;
}
$entity->setLastUpdatedBy($user);
}
}
}

Woohoo! Thanks to the new auto-wiring stuff in Symfony 3.3, we don't need to configure anything in services.yml. Yep, with some help from the type-hint, Symfony already knows what to pass to our $tokenStorage argument.

So go back, refresh and... no errors! It's always creepy when things work on the first try. Go to the show page for the User id 20. Last updated by is set!

Next, we're going to hook into the bundle further and learn how to completely disable actions based on security permissions.

Leave a comment!

11
Login or Register to join the conversation
Peter Avatar
Peter Avatar Peter | posted 4 years ago | edited

(Note for some reason I can't see the entire post disqus is being weird)
So I am having a bit of a complicated problem (It is related I assure you), I had a thought what if I had a base entity that implemented a UpdatedByUser trait for all the entities, then I a global event within easy admin to update this field when ever an admin user updates a table.
Pretty cool, well, auditing has been my favourite things of all time, also being able to blame the person has merit too.

I am getting the`
Call to a member function setLastUpdatedBy() on array`
error, and I am not sure why nor do I understand it.

So I created a trait:


trait UpdatedByUser
{
    /**
     * @ORM\OneToOne(targetEntity="User")
     * @ORM\JoinColumn(name="last_updated_by_id", referencedColumnName="id", nullable=true)
     */
    protected $lastUpdatedBy;

    /**
     * @return mixed
     */
    public function getLastUpdatedBy()
    {
        return $this->lastUpdatedBy;
    }

    /**
     * @param mixed $lastUpdatedBy
     */
    public function setLastUpdatedBy(User $lastUpdatedBy): void
    {
        $this->lastUpdatedBy = $lastUpdatedBy;
    }

}

Then in my entities I simply used the trait - awesome.

So I come to the event:


public function onPreUpdate(GenericEvent $event)
{
    $entity = $event->getSubject();
    $user = $this->storage->getToken()->getUser();
    if (!$user instanceof User) {
        $user = null;
    }
    $entity->setLastUpdatedBy($user);

}

And try it then BOOM..


Call to a member function setLastUpdatedBy() on array

I get an error I simply do NOT understand, Yes I did some hunting around just to try and understand it. Ideas?

1 Reply
Victor Avatar Victor | SFCASTS | Peter | posted 4 years ago | edited

Hey Peter,

The answer probably is very simple: onPreUpdate() is called on every entity, so first of all you need to make sure you call setLastUpdatedBy() on the correct entity. The easiest solution is to add a check before calling setLastUpdatedBy() to make sure we should handle the entity:


public function onPreUpdate(GenericEvent $event)
{
    $entity = $event->getSubject();

    // If method does not exist - we should not handle this entity!
    if (!method_exists($entity, 'setLastUpdatedBy')) {
        return;
    }

    $user = $this->storage->getToken()->getUser();
    if (!$user instanceof User) {
        $user = null;
    }

    $entity->setLastUpdatedBy($user);
}

Instead of "method_exists()" you may use "$entity instanceof YourEntityNameHere", but if you want to handle more than one entity - I'd recommend to create an interface with "setLastUpdatedBy()" declared and implement it in any entity that has setLastUpdatedBy(). Then you can check as "$entity instanceof YourCustomInterfaceNameHere".

Cheers!

1 Reply
Peter Avatar
Peter Avatar Peter | Victor | posted 4 years ago | edited

I see what your saying, but when I add the trait to the entity, it includes the set/getLastUpdatedBy methods, so using an interface would seem redundant. The ttrait methods work without any problems, but for some reason when this event fires it dosn't see the method. I am probably giving myself a place to look, I guess asking here was my hope that I could short circuit some debugging, since you guys use Symfony more than I do :)

And to add more this since I have been debugging, even if I do`
dd($entity->getId());`
it still reports back with the "Call to a member function getId() on array" error. Now every Entity has an ID, it's set directly in the entity not through a trait.

Lucky I have no hair left to pull out. :)

Update 45 min later, I discovered that PRE_EDIT gives an array, where as PRE_UPDATE gives an object in the subject! well problem solved as to why events where not firing.

Thanks for your help.

Reply

Hey Peter,

Nope, it won't be redundant! Because Trait and interface are DIFFERENT things :) Interfaces require you to have the declared methods in it when traits just give you some implementation -so you can use both to make your code even more cool.

Glad you got it working, great catch! Though, checking if your entity is instance of some class or interface in your listener is still a good idea and probably the best practice! It will prevent possible odd cases like this one later.

Cheers!

Reply
Peter Avatar
Peter Avatar Peter | Victor | posted 4 years ago | edited

Hey Victor

Yeah I understand that about interfaces and its a great way to enforce that methods are implemented, but there is no implementation requirements for the trait as it's independent. Maybe I don't understand what your saying.
Note: I modelled the trait after TimeStampable Trait from the Gedmo package.

as for the code here is the final bits:



public static function getSubscribedEvents()
{
    return [
        EasyAdminEvents::PRE_UPDATE => 'onPreUpdate'
    ];
}

public function onPreUpdate(GenericEvent $event)
{

    // TODO: Put in security to make sure that only admin users can update users.
    $entity = $event->getSubject();

    if(!method_exists($entity, 'setLastUpdatedBy')) {
        return;
    }

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

    if (!$user instanceof User) {
        $user = null;
    }

    $entity->setLastUpdatedBy($user);

}

Reply

Hey Peter,

Yeah, code looks great! About interfaces - yes, it's up to you. I'm just saying that you along the trait you use in your entities you can also create and implement an interface e.g. BlamableInterface. Yes, traits are independent, but that interface won't make those traits dependent - it just helps you to identify (group) similar entities and gives you confident that some necessary methods exist on the entity - I'd say your code will be more robust because it's perfect case here when you first check if method implements an interface with "instanceof", and then if so - call some specific methods on the object. But yeah, it's not required and you can bypass it with method_exists(). I just think using interfaces here would make code clearer.

Cheers!

Reply
Peter Avatar

Ahh, now I understand. Yeah your right it would make it clearer if in 12 months I go back to it and think "what the hell was I thinking" where as I could simple look at the interface line and get clarity on the code I am reading.

Thanks for spending the time to give me your thoughts.

Reply

Haha, yeah, but I'd also leave some comments just in case, because you know, 12 months is loooong ;)

Glad you agree!

Cheers!

Reply
Kaizoku Avatar
Kaizoku Avatar Kaizoku | posted 4 years ago

Hi Team !

I struggle on two issues based on events, collection and doctrine uniqueConstraints

> uniqueConstraints is detected only when trying to insert in the DB which cause an exception.
So tried to catch the persist with an EventSubscriber.
I catch the prePersist event, but from here
> how can I cancel the persist and return a flash message ?

Thanks for reading me !

Reply

Hey Kaizoku!

> uniqueConstraints is detected only when trying to insert in the DB which cause an exception.

Hmm, so you have a UniqueConstraints annotation above your entity? In that case, you *also* need to add some Symfony validation, so that form validation fails. This will prevent the entity from being saved. How? With https://symfony.com/doc/cur... - configure this to check for the same unique fields as your UniqueConstraints.

I think this should fully solve your issue :).

Cheers!

Reply
Kaizoku Avatar

W00T ! This works like a charm.
Ryan you're Chuck Noris rockstar ninja !

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on an older version of Symfony & EasyAdminBundle. Many of the concepts are the same, but you can expect major differences in newer versions.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.3.*", // v3.3.18
        "doctrine/orm": "^2.5", // v2.7.2
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.5
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.7
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.17.0
        "sensio/distribution-bundle": "^5.0", // v5.0.25
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.4
        "knplabs/knp-markdown-bundle": "^1.4", // 1.7.1
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
        "stof/doctrine-extensions-bundle": "^1.2", // v1.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "javiereguiluz/easyadmin-bundle": "^1.16" // v1.17.21
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.7
        "symfony/phpunit-bridge": "^3.0", // v3.4.40
        "nelmio/alice": "^2.1", // v2.3.5
        "doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
    }
}
userVoice