Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Extending with Events

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 $12.00

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

Login Subscribe

So far, we've added behavior to our code by overriding methods in our controllers. And that is a great approach, and will be what you should use in most cases. But there is another possibility: events.

Over in QuestionCrudController, up in configureFields()... let's return one more field: yield AssociationField::new('updatedBy').

This field - that lives on the Question entity - is a ManyToOne to User. The idea is that, whenever someone updates a Question, this field will be set to the User object that just updated it. Let's make this only show up on the detail page: ->onlyOnDetail().

... lines 1 - 19
class QuestionCrudController extends AbstractCrudController
{
... lines 22 - 46
public function configureFields(string $pageName): iterable
{
... lines 49 - 92
yield AssociationField::new('updatedBy')
->onlyOnDetail();
}
... lines 96 - 104
}

Right now, in our fixtures, we are not setting that field. So if we go to any question... it says "Updated By", "Null". Our goal is to set that field automatically when a question is updated.

A great solution for this would be to use the doctrine extensions library and its "blameable" feature. Then, no matter where this entity is updated - inside the admin or not - the field would automatically be set to whoever is logged in.

Discovering the Events

But let's see if we can achieve this just inside our EasyAdmin section via events. EasyAdmin has a bunch of events that it dispatches and the best way to find them is to go into the source code. In EasyAdmin's vendor code, open the src/Event/ directory. Most of these are... pretty self explanatory! BeforeCrudAction is dispatched at the start when any CRUD action is executed, "after" would be at the end of that action.. and we also have a bunch of things related to entities, like BeforeEntityUpdatedEvent or BeforeEntityPersistedEvent, where "persisted" means "created".

For our case, the one I'm looking at is BeforeEntityUpdatedEvent. If we could run code before an entity is updated, we could set this updatedBy field and then let it save naturally. Let's do that.

Creating The Event Subscriber

Open up BeforeEntityUpdatedEvent and copy its namespace. Then, over on our terminal, run:

symfony console make:subscriber

Let's call it BlameableSubscriber. It then asks us which event we want to listen to, and it suggests a bunch from the core of Symfony. The one from EasyAdminBundle won't be here, so, instead, I'll paste its namespace, then go grab its class name... and paste that too.

And... perfect! We have a new BlameableSubscriber class! Go open that up: src/EventSubscriber/BlameableSubscriber.php.

... lines 1 - 4
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent;
class BlameableSubscriber implements EventSubscriberInterface
{
public function onBeforeEntityUpdatedEvent(BeforeEntityUpdatedEvent $event)
{
// ...
}
public static function getSubscribedEvents()
{
return [
BeforeEntityUpdatedEvent::class => 'onBeforeEntityUpdatedEvent',
];
}
}

This is a normal Symfony event subscriber and, thanks to auto configuration, Symfony will instantly see this and start using it. In other words, whenever EasyAdmin dispatches BeforeEntityUpdatedEvent, it will call our method.

This $event object is packed with useful info. For example, if I just say $event->, one method is called getEntityInstance(), which is exactly what we want.

To be able to set the updatedBy property on our question, we're going to need the current user object, which we get via the security service. Let's autowire that: add public function __construct() - with a Security $security argument. Hit "alt" + "enter" and go "Initialize properties" to create that property and set it.

... lines 1 - 6
use Symfony\Component\Security\Core\Security;
... line 8
class BlameableSubscriber implements EventSubscriberInterface
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
... lines 17 - 28
}

Love it. Below, start with $question = $event->getEntityInstance(). And then if (!$question instanceof Question), just return... because this is going to be called when every entity is saved across our entire system. Next, $user = $this->security->getUser() and if (!$user instanceof User), let's throw a new: LogicException()... the exception class doesn't matter. This is a situation that will never actually happen: we only have one User class in our app. So if you're logged in, you will definitely have this User instance. But, this helps our editor and static analysis tools.

... lines 1 - 19
public function onBeforeEntityUpdatedEvent(BeforeEntityUpdatedEvent $event)
{
$question = $event->getEntityInstance();
if (!$question instanceof Question) {
return;
}
$user = $this->security->getUser();
// We always should have a User object in EA
if (!$user instanceof User) {
throw new \LogicException('Currently logged in user is not an instance of User?!');
}
... lines 32 - 33
}
... lines 35 - 43

Down here... we can now say $question->setUpdatedBy(), and pass $user.

... lines 1 - 19
public function onBeforeEntityUpdatedEvent(BeforeEntityUpdatedEvent $event)
{
... lines 22 - 32
$question->setUpdatedBy($user);
}
... lines 35 - 43

Let's try it. This question's "Updated By" is "Null". Edit something (make sure you actually make a change so it saves), hit "Save changes" and... got it! "Updated By" is populated! And that is my current user. Sweet!

Alternative: Overriding a Method

So events are a powerful concept in EasyAdmin. However, they're a little bit less important in EasyAdmin 3 and 4 than they used to be. And that's because most of our configuration is now written in PHP in our controller. So instead of leveraging events, there's often an easier way: we can just override a method in our controller.

Event subscribers still have their place, because they are a great way to do an operation on multiple entities in your system. But if you only need to do something on one entity... it's easier to override a method inside that entity's controller.

Let's try it. I'll got to the bottom of my controllerclass and override yet another method. The methods that we can override are almost a README of all the different ways that you can extend things. There's a createEntity() method, a createEditForm() method, and the one we want is called updateEntity(). This is the method that actually updates and saves the entity. Before that happens, we want to set the property.

... lines 1 - 20
class QuestionCrudController extends AbstractCrudController
{
... lines 23 - 106
/**
* @param Question $entityInstance
*/
public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
parent::updateEntity($entityManager, $entityInstance);
}
}

Go steal the code from our subscriber... close that event class... paste that in... and hit "OK" to add that use statement. And now we'll tweak some code: $user = $this->getUser()... and then $question is actually going to be $entityInstance. So we can say $entityInstance->setUpdatedBy().

... lines 1 - 110
public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
$user = $this->getUser();
if (!$user instanceof User) {
throw new \LogicException('Currently logged in user is not an instance of User?!');
}
$entityInstance->setUpdatedBy($user);
parent::updateEntity($entityManager, $entityInstance);
}
... lines 121 - 122

If you want to code defensively, since there's no type-hint on $entityInstance, we could do another check where we say if (!$entityInstance instanceof Question) then throw an exception. But in practice, this will always be a Question object.

Ok: let's see if this works. Go into BlameableSubscriber... and comment out the listener. The subscriber is still here, but it won't do anything anymore. Then go back to Questions... and edit a different question. Actually, before I do that, go look at the details to make sure there's no "Updated By". Perfect! Now edit, make a change, save your changes, and... it still works!

Next, let's do a little bit more with our admin menu, like adding sections to make this whole thing better organized.

Leave a comment!

3
Login or Register to join the conversation
Courtney-T Avatar
Courtney-T Avatar Courtney-T | posted 4 months ago

Where are you hooking in the eventSubscriber? Does it work as a standalone, or do you have to call it somewhere?

Reply
Courtney-T Avatar
Courtney-T Avatar Courtney-T | Courtney-T | posted 4 months ago | edited

I answered my own question, but I've got another one. The entity I'm trying to update doesn't update according to the dump of the entity after I've set the properties I want to update. Do I have to persist the entity?

Reply

Hey @Courtney-T ,

All new entities you created in the current request should be persisted first, i.e. you should call persist($yourNewEntityHere) on them. Otherwise, you won't be saved on flush(). Try to persist it.

Cheers!

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}
userVoice