Buy

Doctrine Event Listeners

In episode 2, we created a registration form and manually encoded the user’s plain-text password before persisting it. We even duplicated this logic in our fixtures. Shame!

Our goal is to encode the user’s password automatically using a Doctrine event listener. These are exactly like a lifecycle callback except that the function that’s executed lives outside of your entity and inside some other class. Do you think this “other class” will be a service? Of course it will :).

Creating the Event Listener

Since I love classes so much, create one called UserListener in a new Doctrine directory of UserBundle:

// src/Yoda/UserBundle/Doctrine/UserListener.php
namespace Yoda\UserBundle\Doctrine;

class UserListener
{

}

We’re going to register this as a service, so the name and location don’t matter at all.

Add a prePersist method. To prove that this is called, just add a die statement:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...

class UserListener
{
    public function prePersist()
    {
        die('Something is being inserted!');
    }
}

Registering the Listener as a Service

Next, let’s register this as a service. Hmm, we don’t already have a services.yml file in UserBundle. Technically, we could just register this in services.yml in EventBundle. But to keep things organized, create a new file in UserBundle and configure the service there.

# src/Yoda/UserBundle/Resources/config/services.yml
services:
    doctrine.user_listener:
        class: Yoda\UserBundle\Doctrine\UserListener

If you think Symfony is going to automatically find this file, you’re nuts! Import it manually from your main config.yml file:

imports:
    # ...
    - { resource: "@UserBundle/Resources/config/services.yml" }

Just like with the Twig Extension, our listener is a service, but Doctrine doesn’t automagically know about it. Let’s use another tag, this time called doctrine.event_listener:

# src/Yoda/UserBundle/Resources/config/services.yml
services:
    doctrine.user_listener:
        class: Yoda\UserBundle\Doctrine\UserListener
        arguments: []
        tags:
            - { name: doctrine.event_listener, event: prePersist }

The name says we’re a listener and event tells Doctrine which event we’re listening to. When Doctrine loads, it looks for all services tagged with doctrine.event_listener and makes sure those services are notified on whatever event is specified.

It’s the moment of truth! Reload the fixtures:

php app/console doctrine:fixtures:load

Yes! Our die function is hit!

To encode the password, copy in the encodePassword from our user fixtures (LoadUsers.php) and rename it to handleEvent. I’ll also make a few other changes, like getting the plain password value off of a plainPassword property and setting the encoded password on the user:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...
use Yoda\UserBundle\Entity\User;
// ...

private function handleEvent(User $user)
{
    $plainPassword = $user->getPlainPassword();
    $encoder = $this->container->get('security.encoder_factory')
        ->getEncoder($user);

    $password = $encoder->encodePassword($plainPassword, $user->getSalt());
    $user->setPassword($password);
}

This function is almost ready.

The Helpful LifecycleEventArgs Callback Argument

Whenever Doctrine calls prePersist, it passes us a special LifecycleEventArgs object. Add an argument for this:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...

use Doctrine\ORM\Event\LifecycleEventArgs;

class UserListener
{
    public function prePersist(LifecycleEventArgs $args)
    {
        die('Something is being inserted!');
    }
}

We can use this to get the actual object being saved. If that object is an instance of User, then we know we want to act on it. If anything else is being saved, we’ll just ignore it. This is important because the function is called when any entity is saved:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...

public function prePersist(LifecycleEventArgs $args)
{
    $entity = $args->getEntity();
    if ($entity instanceof User) {
        $this->handleEvent($entity);
    }
}

Injecting the security.encoder_factory Dependency

We’re almost done. You’ve probably already noticed that the $this->container line won’t work here. We don’t have a $container property - that’s something special to controllers and a few other places.

Again not a problem! The listener ultimately needs the security.encoder_factory service. So let’s just inject it. Add a constructor with this as the first argument:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...

use Symfony\Component\Security\Core\Encoder\EncoderFactory;

class UserListener
{
    private $encoderFactory;

    public function __construct(EncoderFactory $encoderFactory)
    {
        $this->encoderFactory = $encoderFactory;
    }
}

Use the new property in handleEvent:

// src/Yoda/UserBundle/Doctrine/UserListener.php
// ...

private function handleEvent(User $user)
{
    $plainPassword = $user->getPlainPassword();

    $encoder = $this->encoderFactory
        ->getEncoder($user)
    ;

    $password = $encoder->encodePassword($plainPassword, $user->getSalt());
    $user->setPassword($password);
}

The listener is perfect. The last step is to tell the container about the new constructor arugment in services.yml:

# src/Yoda/UserBundle/Resources/config/services.yml
services:
    doctrine.user_listener:
        class: Yoda\UserBundle\Doctrine\UserListener
        arguments: ["@security.encoder_factory"]
        tags:
            - { name: doctrine.event_listener, event: prePersist }

We’re ready! Remove all the encoding logic from LoadUsers and just set the plain password instead:

// src/Yoda/UserBundle/DataFixtures/ORM/LoadUsers.php
// ...

public function load(ObjectManager $manager)
{
    // ...
    // $user->setPassword($this->encodePassword($user, 'darthpass'));
    $user->setPlainPassword('darthpass');

    // ...
    // $admin->setPassword($this->encodePassword($admin, 'waynepass'));
    $admin->setPlainPassword('waynepass');
}

Reload the fixtures again!

php app/console doctrine:fixtures:load

Woh, no errors! Ok, let’s login. Hey, that works too! As long as a new User has a plainPassword, our listener will automatically handle the encoding work for us. With this in place, remove the encoding logic from RegisterController.

Leave a comment!

  • 2015-11-17 Taco

    Yeah that helps! Thanks!

  • 2015-11-16 weaverryan

    Hey Taco!

    You're right in your thinking that it would make sense and be clean, etc. BUT, there is a practical problem: we don't have access to the service (security.encoder_factory) that's needed to encode the password from inside setPlainPassword(). In fact, your entities never have access to services (this is by design - to keep your entities simple). That's why we do it in a listener. If we were doing something simpler (e.g. when you call `setEmail()`, maybe you want to call `setUsername()` to be this same value, for some reason - a silly example) then we would have done it this way.

    Does that help?

  • 2015-11-16 Taco

    Still wondering... Why not update the hashed password in the setPlainPassword method? Wouldn't that make more sense and cleaner code? Is this just to demonstrate events or does it make sense in some other way?

  • 2015-10-13 weaverryan

    Hey again!

    Thanks - I've just fixed the code block at https://github.com/knpuniversi... About the RegisterController (the removal of the code at the end) - I think it's a good idea and we'll include a lot more when we update these tutorials soon. We have new code block technology that we can use to also help make things really clear - example http://knpuniversity.com/scree... :)

    Cheers!

  • 2015-10-13 guest

    Hello again...

    // src/Yoda/UserBundle/Doctrine/UserListener.php
    // ...
    use Yoda\UserBundle\Entity\User;
    // ...
    private function handleEvent(User $user)
    {
    $plainPassword = $user->getPlainPassword();
    $encoder = $this->container->get('security.encoder_factory')->getEncoder($user);
    $password = $encoder->encodePassword($plainPassword(), $user->getSalt());
    $user->setPassword($password);
    }

    Remove brackets after $plainPassword please.

    also it will be great if you could be more consistent and include RegisterController changes into the lesson. It is simple enough but may be useful for some students. So it maybe here just to be sure all on the same wave after the lesson.

  • 2015-04-08 weaverryan

    Awesome, thanks! I fixed these at sha: https://github.com/knpuniversi... and am deploying them now.

    Thanks Mihail!

  • 2015-04-05 Mihail Bashliy

    # src/Yoda/UserBundle/Resources/config/services.yml
    services:
    doctrine.user_listener:
    class: Yoda\UserBundle\Doctrine\UserListener
    arugments: ["@security.encoder_factory"]
    tags:
    - { name: doctrine.event_listener, event: prePersist }

    1. There is a small typo that results in an error:
    arugments => arguments

    2. Another mistake on this page is here:
    // src/Yoda/UserBundle/DataFixtures/ORM/LoadUsers.php
    // ...
    public function load(ObjectManager $manager)
    {
    // ...
    // $admin->setPassword($this->encodePassword($admin, 'waynepass'));
    $user->setPlainPassword('waynepass');
    }

    For the admin ($user->setPlainPassword('waynepass');) should be changed to:
    $admin->setPlainPassword('waynepass');