Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Hooking into Symfony with an Event Subscriber

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Before we dive into the core code, let's hook into the request-response process. Let's create our own listener to this kernel.request event. To do that, in the src/ directory, I already have an EventListener/ directory. It doesn't matter where we put this class, but inside here, let's create a new class called UserAgentSubscriber.

All event subscribers must implement EventSubscriberInterface. I'll go to the Code -> Generate menu on PhpStorm - or Command + N on a Mac - and select "Implement Methods" to generate the one method this interface requires: getSubscribedEvents(). Inside, return an array of all the events we want to listen to, which will just be one.

... lines 1 - 4
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class UserAgentSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
}
}

Now... you might be expecting me to say 'kernel.request' => 'onKernelRequest'. This would mean that when the kernel.request event happens, I want Symfony to call an onKernelRequest() method on this class that we will create in a minute. This would work, but starting in Symfony 4.3, instead of using this made-up kernel.request string, you can pass the event class name, which in this case is RequestEvent::class.

... lines 1 - 5
use Symfony\Component\HttpKernel\Event\RequestEvent;
... line 7
class UserAgentSubscriber implements EventSubscriberInterface
{
... lines 10 - 14
public static function getSubscribedEvents()
{
return [
RequestEvent::class => 'onKernelRequest'
];
}
}

More and more, you'll see documentation that tells you to listen to an event class like this, instead of a random string.

Now, create the function: public function onKernelRequest(). Inside, dump and die it's alive!!!.

... lines 1 - 9
public function onKernelRequest()
{
dd('it\'s alive!!!');
}
... lines 14 - 22

Cool! With any luck, Symfony will call our event listener very early on and it will kill the page. Close the profiler, refresh and... it's alive! Well actually, it's dead, but ya know... that's what we wanted!

Logging in the Listener and Controller

To make the class more interesting, let's log something! You know the drill: add public function __construct() with LoggerInterface $logger. I'll hit Alt+Enter and go to initialize fields as a lazy way to create the property and set it down here.

... lines 1 - 4
use Psr\Log\LoggerInterface;
... lines 6 - 8
class UserAgentSubscriber implements EventSubscriberInterface
{
private $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
... lines 17 - 28
}

In the method, add $this->logger->info() with:

I'm logging SUPER early on the request!

... lines 1 - 17
public function onKernelRequest()
{
$this->logger->info('I\'m logging SUPER early on the request!');
}
... lines 22 - 30

To compare this to logging in a controller, go back to ArticleController. On the homepage action, autowire a $logger argument and say $logger->info():

Inside the controller!

... lines 1 - 8
use Psr\Log\LoggerInterface;
... lines 10 - 13
class ArticleController extends AbstractController
{
... lines 16 - 28
public function homepage(ArticleRepository $repository, LoggerInterface $logger)
{
$logger->info('Inside the controller!');
... lines 32 - 36
}
... lines 38 - 64
}

We expect that the listener will be called first because the RequestEvent, also known as kernel.request, happens before the controller is executed. Refresh the page. It works... and once again, open the profiler in a new tab, click Logs and... perfect! First our listener log and then the controller.

And you can now see our subscriber inside the performance section! Make sure you have the threshold set to 0. Let's see... there it is: UserAgentSubscriber. And then down... way after that... is the controller.

The Event Argument

One of the other "laws" of Symfony's event system is that a listener will always be passed a single argument: an event object. What type of object is it? This is where the new "event class names as event names" comes in handy. We're listening to RequestEvent, which means - surprise! - Symfony will pass us a RequestEvent object! Let's just dd($event).

... lines 1 - 6
use Symfony\Component\HttpKernel\Event\RequestEvent;
... line 8
class UserAgentSubscriber implements EventSubscriberInterface
{
... lines 11 - 17
public function onKernelRequest(RequestEvent $event)
{
dd($event);
... line 21
}
... lines 23 - 29
}

Ok, move back over, close the profiler again, refresh and... there it is! Each event you listen to will be passed a different event object... and each event object will have different super-powers: giving you whatever information you might need for that particular situation, and often, allowing you to change things.

For example, this event contains the Request object... because if you're listening to this very early event in Symfony... there's a good chance that you might want to use the Request object to do something.

In fact, let's do exactly that. Clear out our method and say $request = $event->getRequest(). And then we'll grab the $userAgent off of the request with $request->headers->get('User-Agent'). Finally, let's log this: $this->logger->info() and I'll use sprintf() to say

The User-Agent is %s

Pass $userAgent for the placeholder.

... lines 1 - 17
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
$userAgent = $request->headers->get('User-Agent');
$this->logger->info(sprintf('The User-Agent is "%s"', $userAgent));
}
... lines 25 - 33

Let's check it out! Move over, refresh, open the profiler in a new tab, go down to Logs and... we got it! We're logging the user agent before the controller is called.

Ok! Now that we've hooked into Symfony, let's take a step back and start tracing through everything that happens from the start of the request, line-by-line. We'll even see where the RequestEvent is dispatched and eventually where the controller is executed.

Let's start that journey next.

Leave a comment!

23
Login or Register to join the conversation

Hello, with S6, Php8,1 i got a deprication message
User Deprecated: Method "Symfony\Component\EventDispatcher\EventSubscriberInterface::getSubscribedEvents()" might add "array" as a native return type declaration in the future. Do the same in implementation "App\EventListener\UserAgentSubscriber" now to avoid errors or add an explicit @return annotation to suppress this message.

Reply

Hey Mepcuk

So what's the issue? is anything work bad or unexpected?

PS BTW to fix it will be enough to add : array to the App\EventListener\UserAgentSubscriber::getSubscribedEvents()

Cheers!

Reply
Kevin B. Avatar
Kevin B. Avatar Kevin B. | posted 1 year ago

Heya! Looking to confirm that event subscriber services are lazily instantiated only when the events they listen to are actually dispatched. Some simple tests show they are not in debug mode (guessing because of the traceable dispatcher) but are in prod (non-debug mode).

I recently converted several subscribers to service subscribers to make them "lazy" but looks like that wasn't required.

Reply

Yo Kevin B. !

Ah, that laziness is a really good point to bring up! Ok, so let's look at this:

A) You're correct that event subscribers (and listeners) are lazy in the "prod" environment, meaning that your listener/subscriber service won't be instantiated unless the event is actually dispatched. I haven't tested this, but if I'm wrong, it would be a big ol' bug in Symfony :).

B) What about the dev environment? I had assumed it worked the same... though that was just a guess. I just did a quick and dirty test on my side and it seems like my event subscriber IS indeed still lazy in the dev mode (tested by putting a die() in the constructor of an event subscriber for a security-related event and then refreshed the page). You're seeing something different? It doesn't really matter, of course, but I'm curious.

So... you're correct that you "shouldn't" need to convert your event subscriber/listener into a "service subscriber" (to help others, the term "service subscriber" here has nothing to do with an event subscriber - we're talking about this concept https://symfonycasts.com/sc... ). That's because, as you already know, your listener/subscriber service isn't instantiated unless it's necessary.

However, if you have an event subscriber/listener that is called very often (most importantly the "kernel" events)... but that only does its real work SOME of the time, then you might still want a subscriber so that your subscriber/listener can be instantiated, executed, but then "exit early" ("no work to do on this request!") before instantiating any services. So, your conversion to service subscribers might still have been a good idea :).

Cheers!

Reply
Kevin B. Avatar

Thanks for the confirmation!

What about the dev environment?

I did the same dd in constructor thing but I should note that the testing I did was in 4.4 so maybe some things have changed. Regardless, in prod, they are lazy.

So, your conversion to service subscribers might still have been a good idea

Indeed, the request subscribers I converted were definitely a good call. It's the non-standard event subscribers (3rd party) where I made the unnecessary conversion.

Reply

I have project with EasyAdmin3 and i do not have logs in Symfony profiler -> logs tabs. However can clearly see it in terminal, dev server runned by symfony serve command.

Reply

Hey Mepcuk!

Try running composer require symfony/monolog-bundle - that's my guess.

By default, Symfony ships with a barebones logger service. One thing it does (which is kind of annoying) is that it dumps out to the terminal. But when you install Monolog, the default logger is replaced with the real one. I don't remember for sure, but my guess is that you only see the logs in the profiler once you have the "real" logger installed.

Cheers!

Reply
Default user avatar
Default user avatar Peter | posted 2 years ago | edited

Hello,

I am kind of struggling with even subscriber:


App\EventListener\CreatedUpdatedListener:
        calls:
            - [ setUser, [ "@security.token_storage" ] ]
        tags:
            -
                name: 'doctrine.event_listener'
                event: 'prePersist'

            -
                name: 'doctrine.event_listener'
                event: 'preUpdate'

so when I am saving or updating it should trigger below, basically set createdBy,At,updatedBy,At


class CreatedUpdatedListener
{

    /** @var User */
    private $user;

    public function setUser(TokenStorageInterface $tokenStorage)
    {
        $this->user = $tokenStorage->getToken()?->getUser();
        // can be anon. ie when loading fixtures
        if(is_string($this->user)){
            $this->user = null;
        }
    }

    public function prePersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();

        dd('ARE WE HERE?');
        // check also for null because sometimes we can set properties explicitly and this would always overwrite it
        if (method_exists($entity, 'setCreatedAt') && $entity->getCreatedAt() == null) {
            $entity->setCreatedAt(new \DateTime());
        }
        if (method_exists($entity, 'setUpdatedAt') && $entity->getUpdatedAt() == null) {
            $entity->setUpdatedAt(new \DateTime());
        }
        if (method_exists($entity, 'setCreatedBy') && $entity->getCreatedBy() == null) {
            $entity->setCreatedBy($this->user ? $this->user->getUsername() : 'Not Known');
        }
        
        if (method_exists($entity, 'setUpdatedBy') && $entity->getUpdatedBy() == null) {
            $entity->setUpdatedBy($this->user ? $this->user->getUsername() : 'Not Known');
        }
    }

    public function preUpdate(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (method_exists($entity, 'setUpdatedAt') && $entity->getUpdatedAt() == null) {
            $entity->setUpdatedAt(new \DateTime());
        }

        if (method_exists($entity, 'setUpdatedBy') && $entity->getUpdatedBy() == null) {
            $entity->setUpdatedBy($this->user ? $this->user->getUsername() : 'Not Known');
        }
    }
}

Then I will click on create or save but this event is not even triggered.


$obj = new Object();
$manager->persist($obj);
$manager->flush();

I cleared cache and it wont display dd(Are we here);

Reply

Hey Peter!

Hmm. So this is a different event system than the one we talk about here, but both work using the same fundamental principle of dependency injection tags. So, I don't see anything obviously wrong with your code: you've attached the tag correctly. So, it's time to debug! There are 2 things I can think of:

1) Run php bin/console debug:container App\EventListener\CreatedUpdatedListener and verify that the "tags" are listed on it. Alternatively, you can run php bin/console debug:container --tags to see which services have the doctrine.event_listener tag.

2) Internally, the class (compiler pass) that handles finding the services with the tag is here: https://github.com/symfony/symfony/blob/c71c8727cc665c9e9b56e299fcfcc0adfbf02bac/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterEventListenersAndSubscribersPass.php#L75 - I would debug that $taggedServices variable to verify your service is there).

Let me know if this helps!

Cheers!

Reply
Will T. Avatar
Will T. Avatar Will T. | posted 2 years ago

- lorenzo/pinky 1.0.5 requires ext-xsl * -> it is missing from your system. Install or enable PHP's xsl extension.
I'm getting this error on composer install
I tried composer update, but that didn't work.
Can't find anything regarding xsl in php.ini (This extension has no configuration directives defined in php.ini.)

Reply
Will T. Avatar

nevermind. I had to enable them in php.ini. Was using 7.4, switched to 7.3, but not sure if that had to do with it.
If I went back to the first video conversation I would've seen someone solved this already.

Reply

No worries Will T. sorry you hit the issue! Most operating systems come with some standard PHP extensions like this... so I sometimes forget that a few OS's / ways to install PHP do *not* and cause problems.

Good luck after this! Cheers!

1 Reply
Will T. Avatar

I recently moved from Mac to Windows after being a long time Mac user. Macbook was 10 years old and in dire need of an upgrade. I can get so much more value for a loaded PC.
Finding it can be some what more challenging to get set up running, though, AMP on OSX is not without it's own frustrations.

I think even though it's irritating, it becomes more like working on a job; it gives me valuable troubleshooting skills that give me more experience.

Reply

Hey Will T.!

Switching between *anything* will definitely involve a few bumps on the road - probably you're already through the worst - and I love that attitude! Plus, with the Unix subsystem, Windows has come a long way to being a good dev environment :).

Cheers!

Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | posted 3 years ago | edited

Hello guys,
I'm trying to use Swiftmailer with EventSubscriber. Basically, after the user is created with a POST, I would like to send an Email. Unfortunately, I'm missing something. In the .env I declare the configuration MAIL_URL and then the follow Subscriber.


final class UserMailSubscriber implements EventSubscriberInterface
{
    private $mailer;

    public function __construct(Swift_Mailer $mailer)
    {
        $this->mailer = $mailer;
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::VIEW => ['sendMail', EventPriorities::PRE_VALIDATE],
        ];
    }

    public function sendMail(ViewEvent $event): void
    {
        $user = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();

        if (!$user instanceof User || Request::METHOD_POST !== $method) {
            return;
        }

        $message = (new Swift_Message('A new user is registered'))
            ->setFrom('tmessagerie@norsys.fr')
            ->setTo($user->getEmail())
            ->setBody(sprintf('The user #%d has been added.', $user->getId()));

//        dd('here');

        $this->mailer->send($message);
    }
}

<blockquote>Registered Listeners for "kernel.view" Event
============================================


Order Callable Priority


#1 ApiPlatform\Core\Validator\EventListener\ValidateListener::onKernelView() 64
#2 ApiPlatform\Core\EventListener\WriteListener::onKernelView() 32
#3 App\EventSubscriber\UserMailSubscriber::sendMail() 31
#4 ApiPlatform\Core\EventListener\SerializeListener::onKernelView() 16
#5 ApiPlatform\Core\EventListener\RespondListener::onKernelView() 8


</blockquote>

The problem: the method sendMail is not called baecause (I think) the event subscriber doesn't work.
Could you help me, please?

Reply

Hey Gaetano,

It would be good to know for sure if you hit the sendMail() or no. I see you have dd() commented out, did you run the website with it uncommented? Didn't you hit that "dd()" line? Also, I don't see what namespaces you're using... could you show what namespace are used in your UserMailSubscriber? Probably you extend a wrong namespace so you listener was never called, though I see it in the output... so probably that extra check in sendMail() does not work for you? I'm talking about "if (!$user instanceof User || Request::METHOD_POST !== $method) {", could you double-check its logic? It would be good to debug why you hit that return statement. Are you sure User's namespace is correct? Do you really have POST request?

Btw, why do you want to send the email via listener? How users registered in your application? Do you FOSUserBundle?

Cheers!

Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | Victor | posted 3 years ago | edited

Hi,

thanks for your help. I explain you the project. There is a front-end (Angular) and a back-end (ApiPlatform). When I hit the route ../api/v1/users/create, a user is registered in the database and after registration, my Mailsubscriber send an email. Finally I changed strategy and it works.
This my Subscriber:


namespace App\EventSubscriber;

use App\Event\UserCreatedEvent;
use Swift_Mailer;
use Swift_Message;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;

final class UserMailSubscriber implements EventSubscriberInterface
{
    /**
     * @var VerifyEmailHelperInterface
     */
    protected $verifyEmailHelper;

    /**
     * @var Swift_Mailer
     */
    protected $mailer;

    /**
     * @var Environment
     */
    protected $templating;

    /**
     * UserMailSubscriber constructor.
     * @param VerifyEmailHelperInterface $verifyEmailHelper
     * @param Swift_Mailer $mailer
* @param Environment $templating
     */

    public function __construct(VerifyEmailHelperInterface $verifyEmailHelper, Swift_Mailer $mailer, Environment $templating)
    {
        $this->verifyEmailHelper = $verifyEmailHelper;
        $this->mailer = $mailer;
        $this->templating = $templating;
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents(): array
    {
        return [
            UserCreatedEvent::NAME => 'onUserCreated'
        ];
    }

    /**
     * @param UserCreatedEvent $event
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function onUserCreated(UserCreatedEvent $event): void
    {
        $user = $event->getUser();
        $message = (new Swift_Message())
            ->setFrom('tmessagerie@norsys.fr', 'Topolino Test')
            ->setTo($user->getEmail())
            ->setSubject('Welcome to Paradise')
            ->setBody(
                $this->templating->render(
                    'creationUser/confirmation_email.html.twig',
                    [
                        'username' => $user->getUsername()
                    ]
                ),
                'text/html'
            );

        $this->mailer->send($message);
    }
}

It calls my event:


namespace App\Event;

use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;

class UserCreatedEvent extends Event
{
    public const NAME = 'user.created';

    /**
     * @var User
     */
    protected $user;

    /**
     * UserCreatedEvent constructor.
     * @param User $user
     */

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    /**
     * @return User
     */
    public function getUser(): User
    {
        return $this->user;
    }
}

Then in my userManager (you see only a part of code) I use the dispatcher:


/**
 * @param User $user
 * @return array
 */
public function registerAccount(User $user): array
{
    if ($this->findEmail($user->getEmail())){
        throw new BadRequestHttpException('Email already exists');
    }

    $user->setUsername($user->getEmail());
    $password = $this->passwordServices->encode($user, $user->getPassword());
    $user->setPassword($password);
    $user->setReference($this->referenceFormat());
    $this->entityManager->persist($user);
    $this->entityManager->flush();
    $event = new UserCreatedEvent($user);
    $this->dispatcher->dispatch($event, UserCreatedEvent::NAME);

    return [
        "message"   => "User creation performed",
        "user"      => $user
    ];
}

The registeraccount method is called by the __invoke($data) inside the createUser class (this is the controller called in the collectionOperations by ApiResource).
I hope this explanation is good to better understand.
Thanks again for your help.

Reply
Default user avatar
Default user avatar Paul Rijke | posted 3 years ago

Great overview Ryan. One thing I don't understand. You don't configure the eventsubscriber in the services.yml and it works out-of-the-box. But when I want to subscribe to a doctrine event I have to put it explicitely in the services.yml, also according to this docs https://symfony.com/doc/cur...

Can you explain why?

Reply

Hey Paul Rijke

That's a good question. The thing here is that Doctrine comes with its own event-dispatching system, in other words Symfony events and Doctrine events are two different systems. Symfony has the "auto configure" feature, so you only need to implement the "EventSubscriber" interface and forget configuration but because of what I said Doctrine cannot benefit of that feature and you have to add the required configuration.

Cheers!

Reply
Cat in space

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

This tutorial also works well for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-iconv": "*",
        "antishov/doctrine-extensions-bundle": "^1.4", // v1.4.3
        "aws/aws-sdk-php": "^3.87", // 3.133.20
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.2.2
        "doctrine/orm": "^2.5.11", // 2.8.2
        "easycorp/easy-log-handler": "^1.0", // v1.0.9
        "http-interop/http-factory-guzzle": "^1.0", // 1.0.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.9.0
        "knplabs/knp-paginator-bundle": "^5.0", // v5.4.2
        "knplabs/knp-snappy-bundle": "^1.6", // v1.7.1
        "knplabs/knp-time-bundle": "^1.8", // v1.16.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.24
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.9.1
        "liip/imagine-bundle": "^2.1", // 2.5.0
        "oneup/flysystem-bundle": "^3.0", // 3.7.0
        "php-http/guzzle6-adapter": "^2.0", // v2.0.2
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.1", // v5.6.1
        "symfony/asset": "5.0.*", // v5.0.11
        "symfony/console": "5.0.*", // v5.0.11
        "symfony/dotenv": "5.0.*", // v5.0.11
        "symfony/flex": "^1.9", // v1.17.5
        "symfony/form": "5.0.*", // v5.0.11
        "symfony/framework-bundle": "5.0.*", // v5.0.11
        "symfony/mailer": "5.0.*", // v5.0.11
        "symfony/messenger": "5.0.*", // v5.0.11
        "symfony/monolog-bundle": "^3.5", // v3.6.0
        "symfony/property-access": "5.0.*|| 5.1.*", // v5.1.11
        "symfony/property-info": "5.0.*|| 5.1.*", // v5.1.10
        "symfony/routing": "5.1.*", // v5.1.11
        "symfony/security-bundle": "5.0.*", // v5.0.11
        "symfony/sendgrid-mailer": "5.0.*", // v5.0.11
        "symfony/serializer": "5.0.*|| 5.1.*", // v5.1.10
        "symfony/twig-bundle": "5.0.*", // v5.0.11
        "symfony/validator": "5.0.*", // v5.0.11
        "symfony/webpack-encore-bundle": "^1.4", // v1.11.1
        "symfony/yaml": "5.0.*", // v5.0.11
        "twig/cssinliner-extra": "^2.12", // v2.14.3
        "twig/extensions": "^1.5", // v1.5.4
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.0
        "twig/inky-extra": "^2.12", // v2.14.3
        "twig/twig": "^2.12|^3.0" // v2.14.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.4.0
        "fakerphp/faker": "^1.13", // v1.13.0
        "symfony/browser-kit": "5.0.*", // v5.0.11
        "symfony/debug-bundle": "5.0.*", // v5.0.11
        "symfony/maker-bundle": "^1.0", // v1.29.1
        "symfony/phpunit-bridge": "5.0.*", // v5.0.11
        "symfony/stopwatch": "^5.1", // v5.1.11
        "symfony/var-dumper": "5.0.*", // v5.0.11
        "symfony/web-profiler-bundle": "^5.0" // v5.0.11
    }
}
userVoice