Jump to content

difference between Symfony Events and Event Listners and Doctrine Events


NotionCommotion

Recommended Posts

I am a little confused with the difference between Symfony Events and Event Listners and Doctrine Events.

The Doctrine events look pretty straight forward and are primary used for entity persistence, and I have outlined my understanding below:

  • Doctrine Lifecycle Callbacks.
    • Single entity and single Doctrine event.
    • Method in class.
    • Good performance.
    • Don't have access to Symfony services (all others do)
  • Doctrine Lifecycle Listeners.
    • All entities and single Doctrine event.
    • Separate class and configured in config.service.yaml.
  • Doctrine Entity Listeners.
    • Single entities and single Doctrine event.
    • Separate class and configured in config.service.yaml.
  • Doctrine Lifecycle Subscribers.
    • All entities and multiple Doctrine event.
    • Must implement EventSubscriber (or probably EventSubscriberInterface)
    • Separate class and configured in config.service.yaml.

I am more confused with the Symfony events and my interpretation as listed below is likely incorrect.

  • Symfony Event Listeners.
    • Single Symfony event.
    • Separate class and configured in config.service.yaml.
    • More flexible because bundles can enable or disable each of them conditionally depending on some configuration value.
  • Symfony Event Subscribers.
    • All specified Symfony events.
    • Must implement EventSubscriberInterface
    • Separate class but NOT configured in config.service.yaml but in class.
    • Easier to reuse because the knowledge of the events is kept in the class rather than in the service definition.

Are they used for totally different purposes or can one use Symfony events to also deal with entities?  Where would one want to use these Symfony events?

Is there a reason why Doctrine Lifecycle Subscribers are located in src/EventListener and not src/EventSubscriber

Are Doctrine Lifecycle and Entity Listeners really only for a single event as I interpret the documentation states, or is it one method per Doctrine event such as the following?

    App\EventListener\SearchIndexer:
        tags:
            -
                name: 'doctrine.event_listener'
                event: 'postPersist'
            -
                name: 'doctrine.event_listener'
                event: 'postUpdate'

 

Link to comment
Share on other sites

2 hours ago, NotionCommotion said:

Are they used for totally different purposes or can one use Symfony events to also deal with entities?  Where would one want to use these Symfony events?

Doctrine's event system is specifically designed to let you deal hook into the entity processing/database management system.  That's the only thing they are really good for.  If that's what you want to do then you'll probably want to use doctrine's events to accomplish your goal.

Symfony's event system is just a generic event system that lets a person define events in one area and then subscribe to them in another area.   You'd use these whenever you want to design a system where you want or need to be able to extend the functionality in some way or just keep track of what's going on.  

For example, I have a system where a user creates a committee of other users which then has the be approved later on by an administrator.  The code that handles the approval just sets the approved timestamp then dispatches a "Committee Approved" event using the Symfony system.  I then have a separate class which subscribes to that event and handles sending email notifications that the committee was approved.   I split these tasks by using an event for a couple reasons

  1. The code is cleaner this way
  2. Maybe someday I'll want to do other things "when a committee is approved" and can just add another subscriber rather than mess with the approval code.

 

2 hours ago, NotionCommotion said:

Is there a reason why Doctrine Lifecycle Subscribers are located in src/EventListener and not src/EventSubscriber

Just the way someone decided to do it I'd imagine.  You could do it differently if you want.  I don't use generic namespaces/folders like that and prefer to be more specific when possible.  For example, that committee approval notification is in src/Notification/CommitteeApprovedEmailer.php.

 

2 hours ago, NotionCommotion said:

Are Doctrine Lifecycle and Entity Listeners really only for a single event as I interpret the documentation states, or is it one method per Doctrine event such as the following?

The doctrine documentation seems to suggest that you can listen for multiple events in a single class.  How you'd accomplish such a thing via Symfony's configuration file I don't know.  Symfony might have limit it to a single event, or maybe you can pass an array to event: to define multiple.  One would have to experiment or dig into the source code to find out for sure.

 

  • Thanks 1
Link to comment
Share on other sites

 

10 hours ago, kicken said:

For example, I have a system where a user creates a committee of other users which then has the be approved later on by an administrator.  The code that handles the approval just sets the approved timestamp then dispatches a "Committee Approved" event using the Symfony system.  I then have a separate class which subscribes to that event and handles sending email notifications that the committee was approved.   I split these tasks by using an event for a couple reasons

  1. The code is cleaner this way
  2. Maybe someday I'll want to do other things "when a committee is approved" and can just add another subscriber rather than mess with the approval code.

Currently, I am using Doctrine events (prePersist or preUpdate as necessary) to do similar.  For instance, new User is added but their password isn't yet hashed and they also need an account for another API so UserDoctrineEntityListner hashes their password and makes a cURL request to create their account and set's User's otherApiProperty using the response.  On updating some user property which is shared with the other API, I do similarly but don't need the response to update the User entity.  You "could have" done the same when you saved the approved timestamp.  Was your primary reason why not to as your approach is more flexible should you want to do other things "when a committee is approved".  Maybe use my approach only if these other tasks needed to also modify some Doctrine entity or use your approach and have it update the entity before persisting?

10 hours ago, kicken said:

Just the way someone decided to do it I'd imagine.  You could do it differently if you want.  I don't use generic namespaces/folders like that and prefer to be more specific when possible.  For example, that committee approval notification is in src/Notification/CommitteeApprovedEmailer.php.

Thanks!  Takes some of the magic away and makes me confident I can do this!
 

10 hours ago, kicken said:

The doctrine documentation seems to suggest that you can listen for multiple events in a single class.  How you'd accomplish such a thing via Symfony's configuration file I don't know.  Symfony might have limit it to a single event, or maybe you can pass an array to event: to define multiple.  One would have to experiment or dig into the source code to find out for sure.

I didn't interpret the documentation that way but I think you are interpreting it correct.
Entity listeners are defined as PHP classes that listen to a single Doctrine event on a single entity class. For example, suppose that you want to send some notifications whenever a User entity is modified in the database. To do so, define a listener for the postUpdate Doctrine event:

For instance, I believe the following will work.

     App\EventListener\UserListner:
        tags:
            -
                name: 'doctrine.orm.entity_listener'
                event: 'prePersist'
                entity: 'App\Entity\Account\User'
            -
                name: 'doctrine.orm.entity_listener'
                event: 'preUpdate'
                entity: 'App\Entity\Account\User'
            -
                name: 'doctrine.orm.entity_listener'
                event: 'preRemove'
                entity: 'App\Entity\Account\User'


As always, appreciate your advise.

Link to comment
Share on other sites

4 hours ago, NotionCommotion said:

For instance, new User is added but their password isn't yet hashed and they also need an account for another API so UserDoctrineEntityListner hashes their password and makes a cURL request to create their account and set's User's otherApiProperty using the response.

I do something similar for the password hashing.   I'd probably have tied in the other account creation via a different method, but it's fine either way I imagine.  Those are two separate tasks though so I would make them into two separate classes to make the code cleaner.   The password hashing bit could easily be made more generic so it can be reused in other areas if needed.  That is what I did for mine.   It looks like this:

class EncodeOnPersist implements EventSubscriber {
    private $factory;
    private $entityList;

    public function __construct(EncoderFactoryInterface $factory){
        $this->factory = $factory;
        $this->entityList = new ArrayCollection();
    }

    public function getSubscribedEvents(){
        return [Events::postLoad, Events::preFlush];
    }

    public function postLoad(LifecycleEventArgs $event){
        $entity = $event->getEntity();
        $this->addEntity($entity);
    }

    public function preFlush(PreFlushEventArgs $event){
        $em = $event->getEntityManager();
        foreach ($em->getUnitOfWork()->getScheduledEntityInsertions() as $entity){
            $this->addEntity($entity);
        }

        foreach ($this->entityList as $entity){
            $this->encodeEntity($entity);
        }

        $em->getUnitOfWork()->computeChangeSets();
    }

    private function addEntity($entity){
        if ($entity instanceof EncodeOnPersistInterface && !$this->entityList->contains($entity)){
            $this->entityList[] = $entity;
        }
    }

    private function encodeEntity(EncodeOnPersistInterface $entity){
        $entity->encodeOnPersist($this->factory);
    }
}

Each entity that needs a value hashed/encoded then just implements this interface:

interface EncodeOnPersistInterface {
    /**
     * @param EncoderFactoryInterface $factory
     * @return void
     */
    public function encodeOnPersist(EncoderFactoryInterface $factory);
}

For example:

class UserAccount implements AdvancedUserInterface, \Serializable, EncodeOnPersistInterface {
    // [...]
    private $password;
    private $newPassword;

    // [...]
    public function encodeOnPersist(EncoderFactoryInterface $factory){
        if ($this->newPassword !== null){
            $encoder = $factory->getEncoder($this);
            $this->password = $encoder->encodePassword($this->newPassword, $this->getSalt());
            $this->eraseCredentials();
        }
    }
}

The same encoder class gets reused for the forgot password tokens and could be reused again for anything else down the road that might need it.

 

Link to comment
Share on other sites

Yes, I need to get over my class stinginess and create separate classes.  At first putting a couple of tasks in a single class doesn't sting me, but eventually often does.

I also like your approach of adding the EncodeOnPersistInterface to any entities that need it. Keeps things in one place.

At first I thought it would be less code duplication to perform the encoding in EncodeOnPersist, but the more I think about it, the more I prefer your implementation.

6 hours ago, NotionCommotion said:
17 hours ago, kicken said:

Just the way someone decided to do it I'd imagine.  You could do it differently if you want.  I don't use generic namespaces/folders like that and prefer to be more specific when possible.  For example, that committee approval notification is in src/Notification/CommitteeApprovedEmailer.php.

Thanks!  Takes some of the magic away and makes me confident I can do this!

One question I forgot to ask.  Are there any "magic" directories that Symfony always/only scans and I need to know about or does it scan all directories without expectations and basis its action based on interfaces?  And scans just for files with a .php extension, true?

Link to comment
Share on other sites

4 hours ago, NotionCommotion said:

At first I thought it would be less code duplication to perform the encoding in EncodeOnPersist, but the more I think about it, the more I prefer your implementation

The idea is to defer the actual encoding to the entity because different entities might want to encode different things.  The user hashes the password but maybe something else needs to base64 something or generate a hmac signature or whatever.  Passing the EncoderFactoryInterface as a parameter lets it tie into symfony's encoding configuration in the security.yml file easily.  The entity can use it or not.

4 hours ago, NotionCommotion said:

Are there any "magic" directories that Symfony always/only scans and I need to know about or does it scan all directories without expectations and basis its action based on interfaces?  And scans just for files with a .php extension, true?

My knowledge on that is probably outdated.  When I started with Symfony the only real magic/scanning was that it'd scan the src/AppBundle/Command folder for *Command.php files and register them as console commands.  A quick look at the documentation suggests that it doesn't do that anymore either. 

The nice thing about Symfony is most of it's "magic" is either stuff you explicitly configure or it's easy to override if necessary.  For the most part everything is just driven with the YAML configuration files and not by scanning files.  I know the newer versions have a feature where you can let it scan for services and register them, but it's not something I've looked into much.  I use it to register my controllers in one project but my other services I declare manually.  The feature is probably fine to use, but my code all comes from before that feature existed. 

Without digging into the code implementing that feature I'm not sure exactly how it works, but the manual says it just uses a standard glob pattern to locate files and shows the default pattern being essentially src/* so that'd imply every file regardless of extension.  I'd guess it probably takes every file and transforms it into a class full class name then does a class_exists() on it which will trigger the auto-loading mechanism to try and load that class. If it can then it'd register that class.  If it fails it probably just skips that file and moves on.  You could check the code if your curious for the details and let me know if I'm right or wrong.

The autoconfigure / autowire features are what rely on interfaces/type-hints to determine what to do.  For example with the console commands now instead of scanning the Command folder it just checks if $class instanceof Symfony\Component\Console\Command\Command and if so registers it as a console command.   These features I do take advantage of which means most of my service definitions end up just being a single line in the services.yml file:

### Full Auto-wire Services
AppBundle\Form\UserRoleType: ~
AppBundle\Form\UserAccountType: ~
AppBundle\Service\Search\StudentSearch: ~
AppBundle\Service\Search\CourseSearch: ~

It's possible to implement your "magic" if you want.  I haven't done it with the newer setup, but with the older versions (2.7) I had some code that would check for services with a tag of app.background_worker, gather them all up into an array and then pass it as a constructor argument to my app.run_background_workers service which was a console command.  I then created a systemd service that would run that console command.  The web app could then submit jobs via gearman which would be picked up by that service and run independently of the web app.

Link to comment
Share on other sites

Thanks kicken,  Good information.

I actually hate the magic.  I waste so much time figuring out why something is happening that shouldn't and finally discover it is something I totally didn't expect.

Regarding scanning files, Symfony 5.2.3 seems to scan all PHP files in src/* as you said.  If I start some file but then don't finish it, I need to change the extension from .php to .php.tmp or I get errors (unless they are cached, sometimes).

Didn't know about that single line service definition.  Thanks.

Link to comment
Share on other sites

Hello again kicken,

I struggled a little on using the correct interfaces and classes.  First tried Symfony\Component\EventDispatcher\EventSubscriberInterface instead of your EventSubscriber, and found not only did it need to be static, also never called my post and pre load methods.  Then tried Doctrine\Common\EventSubscriber and while it didn't need to be static, still never called the post/pre load methods.  Only did Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface work as desired.  Using PHP 8 and Symfony 5.2.3.  Look like I am doing things right but just a different version than you use?

Also, I would have thought to use prePersist and preUpdate, but you used postLoad and preFlush.  Do you mind expanding on this as well as your specific preFlush() code?

Thanks!

 

<?php

namespace App\EventSubscriber;

//use Doctrine\Common\EventSubscriber;
//use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use App\Entity\Account\EncodeOnPersistInterface;

class EncodeOnPersist implements EventSubscriberInterface
{
    private $encoderFactory;
    private $entityList;

    public function __construct(EncoderFactoryInterface $encoderFactory)
    {
        $this->encoderFactory = $encoderFactory;
        $this->entityList = new ArrayCollection();
    }

    public function getSubscribedEvents():array
    {
        return [Events::postLoad, Events::preFlush];
    }

    public function postLoad(LifecycleEventArgs $event):void
    {
        $entity = $event->getEntity();
        $this->addEntity($entity);
    }

    public function preFlush(PreFlushEventArgs $event):void
    {
        $em = $event->getEntityManager();
        foreach ($em->getUnitOfWork()->getScheduledEntityInsertions() as $entity){
            $this->addEntity($entity);
        }

        foreach ($this->entityList as $entity){
            $this->encodeEntity($entity);
        }

        $em->getUnitOfWork()->computeChangeSets();
    }

    private function addEntity($entity):void
    {
        if ($entity instanceof EncodeOnPersistInterface && !$this->entityList->contains($entity))
        {
            $this->entityList[] = $entity;
        }
    }

    private function encodeEntity(EncodeOnPersistInterface $entity):void
    {
        $entity->encodeOnPersist($this->encoderFactory);
    }
}

 

Link to comment
Share on other sites

Doctrine\Common\EventSubscriber is the interface I am referencing.  Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface doesn't exist in my setup so not sure whether there's a difference.  Might just be a change in the newer version.

1 hour ago, NotionCommotion said:

Do you mind expanding on this

It's been a while so my memory is fuzzy.   preFlush is there to handle the encoding process just prior to the changes being committed to the DB.  The whole postLoad thing rather than preUpdate is because preUpdate doesn't get triggered if no mapped field is changed.  Since the $newPassword field isn't tracked by doctrine it won't notice when it's been changed and will ignore the entity if that is the only field that has been changed.  As such, something like this would fail:

$user = $em->find(User::class, 1);
$user->setNewPassword('test1234');
$em->flush();

The solution I came up with at the time was just to watch for any entities that get loaded which implement the interface and keep track of them.  Then when a flush event occurs call their encodeOnPersist method so they can get updated if necessary.  Specifically for user passwords another way around this might have been to set $password to null when setting $newPassword but I didn't think of that at the time and this seems like a better generic method anyway.

1 hour ago, NotionCommotion said:

as well as your specific preFlush() code?

The preFlush basically just does the encoding process.  First it looks for any newly created entities and adds them to the list.  Then it runs though all the known entities and runs their encodeOnPersist method. 

The last line tells doctrine to re-examine the entities for changes.  The way doctrine handles flushing the data is it first examines all the entities and their mapped fields and determines which ones have changed.  The preFlush event is run after that change detection process so if you make a change to any entities it won't be detected unless you tell doctrine about it by having it re-run the change detection.

 

 

Link to comment
Share on other sites

Thanks kicken,

Yeah, my memory is more fuzzy than I wish to admit.

Gotcha on the other parts.  I found that I would need to explicitly set some ORM monitored parameter such as updatedAt so that listeners actually listened to non-ORM monitored properties such as $newPasword.

Appreciate and enjoy the rest of your weekend.

Link to comment
Share on other sites

This thread is more than a year old. Please don't revive it unless you have something important to add.

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...

Important Information

We have placed cookies on your device to help make this website better. You can adjust your cookie settings, otherwise we'll assume you're okay to continue.