Symfony Events - The Gotchas


In this short series on Symfony Events we have covered both Event Listeners, and Event Subscribers, and learned how to make use of the Symfony Event Dispatcher service for our own custom event needs.

So far we've seen some useful ways of interacting with Events.

Unintuitive Behaviours of Symfony Events

Now I want to show you a three possibly unintuitive behaviours.

This is NOT Fire And Forget

Firstly, using the event dispatcher is not fire-and-forget.

Let's say you have a User Registration process.

Much like in our Widget creation process from the previous video let's imagine we create a new User, and then send our a USER__CREATED event.

We'd like it if a few things happened:

  • We wrote a log entry
  • We synced up the user with the payment provider
  • We sent a welcome email
<?php

namespace AppBundle\Event\Subscriber;

use AppBundle\Event\Events;
use AppBundle\Event\WidgetCreatedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class WidgetSubscriber implements EventSubscriberInterface
{
    // * snip *

    public static function getSubscribedEvents()
    {
        return [
            Events::WIDGET__CREATED => [
                ['writeLogEntry'],
                ['syncWithPaymentProvider', 255],
                ['sendWelcomeEmail', 100],
            ],
        ];
    }

    // * snip *

Let's imagine that each one of these processes takes a defined period of time.

We'll say the log entry takes ~1 second.

The sync up with the payment provider takes ~5 seconds.

    public function syncWithPaymentProvider(
        WidgetCreatedEvent $event
    )
    {
        sleep(5);
    }

And a welcome email takes ~2 seconds.

What you might expect is that these events happen concurrently. This seems logical as as we have seen, events can have a priority, so if two events have an equal priority, they may surely fire at he same time?

They do not. They happen in sequence.

One after the other.

First the log, then the sync up, then the welcome email.

Each blocked by the one before it.

Nor does your controller respond to the waiting end user till each one has finished. They would see the 'loading' spinner on their browser tab, for example.

And if anything goes wrong without a try / catch then everything is stopped.

So each time a new User registers, then the time to response will be at least ~1 + ~5 + ~2 = ~8 seconds.

That's excluding any other stuff like persisting the User object to the database that may be happening in the controller action. This is purely for the calls the Event Listener / Event Subscriber setup is going to take.

Possible Solution: If this is an issue to you then likely you want a queue. A queue is fire-and-forget. However, they are also more complex. You will have to make a decision here.

Things Go Wrong - try / catch

In the previous point we saw that the second and third steps in our imaginary event subscriber were to interact with external services (e.g. Stripe, and Mailgun):

There's one thing for certain, especially when dealing with external services:

If it can go wrong, at some point, it will.

Intuitively we can use try / catch.

    public function syncWithPaymentProvider(
        WidgetCreatedEvent $event
    )
    {
        try {

            // e.g some api call to Stripe

        } catch (\Exception $e) {
            $this-logger->warning(
                'ahoy matey, things be awry'
                [
                    'exception' => [
                        'msg' => $e->getMessage(),
                    ]
                ]
        }
    }

Makes sense.

I would strong advise that you do this too, as if you don't, and something does go wrong, then the entire request fails. This may leave you in an unwanted state.

So be careful.

The unintuitive behaviour is that you can wrap the entire Event Dispatcher call in a try / catch:

// src/AppBundle/Controller/WidgetController.php

    public function postAction(
        Request $request,
        EntityManagerInterface $em,
        EventDispatcherInterface $eventDispatcher,
        WidgetFactory $widgetFactory
    )
    {
        // other stuff

        try {
            $eventDispatcher->dispatch(
                Events::WIDGET__CREATED,
                new WidgetCreatedEvent($widget)
            );
        } catch (\Exception $e) {
            // this will catch exceptions
            // from any invoked listener
            // or subscriber
        }

        // other stuff
    }

And the catch block will be triggered for any exception occurring in any of your listeners or subscribers.

Again, this is because events are not fire and forget.

Also, I would advise not creating a general wrapper like that.

your code will look like this if you use this generic try / catch setup

Events Are Not A One Way Deal

Again, this boils down to the fact that events are not fire-and-forget.

Why do I keep mentioning this?

Because I can still picture the face of the poor dev who had built out the registration process from a really quite complex form, and used a big event chain to handle so much of the process. I had been called in to look into why the thing was so slow for new registrations. And when I pointed this out as the problem he went as white as a sheet.

Anyway, because events are not fire-and-forget, they can give you data back.

You could, for example, add a setter to your Event object and have all your events pass data back into the event as each method is called. You can see an example of this in FOSUserBundle.

My personal opinion is that I would not use events in this way.

Even though events are not fire-and-forget, when working with Events my life has been easier when I work as though they are fire-and-forget.

Events Dispatching Events

One last unintuitive behaviour is that event listeners / subscribers can dispatch further events.

Whilst so far we have seen both event listeners and event subscribers used the method signature of e.g.

    public function syncWithPaymentProvider(
        WidgetCreatedEvent $event
    )
    {
        // ...
    }

The full method signature is:

    public function syncWithPaymentProvider(
        WidgetCreatedEvent $event,
        string $eventName,
        EventDispatcherInterface $eventDispatcher
    )
    {
        // ...
    }

So events can go on to dispatch further events, if needed.

In truth, I have never needed this functionality beyond demonstrative purposes.

Conclusion

That about wraps up our journey into custom Symfony events.

What we haven't touched on here is how to hook into Symfony's internal events. The process is the same, but the reasons why we may wish to do so differ from our own events.

Events are extremely useful, but you need to understand their limitations - most notably that they are blocking. As mentioned, if this is a big issue for you, consider using a dedicated queue instead.

Episodes