Book Image

Extending Symfony2 Web Application Framework

By : Sebastien Armand
Book Image

Extending Symfony2 Web Application Framework

By: Sebastien Armand

Overview of this book

Table of Contents (13 chapters)

Listeners


Listeners are a way of implementing the observer's design pattern. In this pattern, a particular piece of code does not try to start the execution of all the code that should happen at a given time. Instead, it notifies all of its observers that it has reached a given point in execution and lets these observers to take over the control flow if they have to.

In Symfony, we use the observer's pattern through events. Any class or function can trigger an event whenever it sees the event fit. The event itself can be defined in a class. This allows the passing of more information to the code observing this event. The framework itself will trigger events at different points in the process of handling the requests. These events are as follows:

  • kernel.request: This event happens before reaching a controller. It is used internally to populate the request object.

  • kernel.controller: This event happens immediately before executing the controller. It can be used to change the controller being executed.

  • kernel.view: This event happens after executing the controller and if the controller did not return a response object. For example, this will be used to let Twig handle the rendering of a view by default.

  • kernel.response: This event happens before the response is sent out. It can be used to modify the response before it is sent out.

  • kernel.terminate: This event happens after the response has been sent out. It can be used to perform any time-consuming operations that are not necessary to generate the response.

  • kernel.exception: This event happens whenever the framework catches an exception that was not handled.

    Note

    Doctrine will also trigger events during an object's lifecycle (such as before or after persisting it to the database), but they are a whole different topic. You can learn everything about Doctrine LifeCycle Events at http://docs.doctrine-project.org/en/latest/reference/events.html.

Events are very powerful and we will use them in many places throughout this book. When you begin sharing your Symfony extensions with others, it is always a good idea to define and trigger custom events as these can be used as your own extension points.

We will build on the example provided in the Services section to see what use we could make of the listeners.

In the first part, we made our site that only shows a user the meetups that are happening around him or her. We now want to show meetups also taking into account a user's preferences (most joined meetups).

We have updated the schema to have a many-to-many relationship between users and the meetups as follows:

// Entity/User.php
/**
 * @ORM\ManyToMany(targetEntity="Meetup", mappedBy="attendees")
 */
protected $meetups;

// Entity/Meetup.php
/**
 * @ORM\ManyToMany(targetEntity="User", inversedBy="meetups")
 */
protected $attendees;

In our controller, we have a simple action to join a meetup, which is as follows:

/**
 * @Route("/meetups/{meetup_id}/join")
 * @Template()
 */
public function joinAction($meetup_id) {
    $em = $this->getDoctrine()->getManager();
    $meetup = $em->getRepository('KhepinBookBundle:Meetup')->find($meetup_id);

    $form = $this->createForm(new JoinMeetupType(), 
      $meetup, 
      ['action' => '', 'method' => 'POST']
    );
    $form->add('submit', 'submit', array('label' => 'Join'));
    $form->handleRequest($this->get('request'));

    $user = $this->get('security.context')->getToken()->getUser();

    if ($form->isValid()) {
        $meetup->addAttendee($user);
        $em->flush();
    }

    $form = $form->createView();
    return ['meetup' => $meetup, 'user' => $user, 
      'form' => $form];
}

Tip

We use a form even for such a simple action because getting all our information from the URL in order to update the database and register this user as an attendee would enable many vulnerability issues such as CSRF attacks.

Updating user preferences using custom events

We want to add some code to generate the new list of favorite meetups of our user. This will allow us to change the logic for displaying the frontpage. Now, we can not only show users all the meetups happening around them, but also data will be filtered as to how likely they are to enjoy this kind of meetup. Our users will view the frontpage often, making the cost of calculating their favorite meetups on each page load very high. Therefore, we prefer to have a pre-calculated list of their favorite meetup types. We will update this list whenever a user joins or resigns from a meetup. In the future, we can also update it based on the pages they browse, even without actually joining the meetup.

The problem now is to decide where this code should live. The easy and immediate answer could be to add it right here in our controller. But, we can see that this logic doesn't really belong here. The controller makes sure that a user can join a meetup. It should limit its own logic to just doing that.

What is possible though is to let the controller call an event, warning all observers that a user has joined a meetup and letting these observers decide what is best to do with this information.

For this event to be useful, it needs to hold information about the user and the meetup. Let's create a simple class using the following code to hold that information:

// Bundle/Event/MeetupEvent.php
namespace Khepin\BookBundle\Event;

use Symfony\Component\EventDispatcher\Event;
use Khepin\BookBundle\Entity\User;
use Khepin\BookBundle\Entity\Meetup;

class MeetupEvent extends Event
{
    protected $user;
    protected $event;

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

    public function getUser() {
        return $this->user;
    }

    public function getMeetup() {
        return $this->meetup;
    }
}

This class is very simple and is only here to hold data about an event regarding a meetup and a user. Now let's trigger that event whenever a user joins a meetup. In our controller, use the following code after validating the form:

if ($form->isValid()) {
    $meetup->addAttendee($user);
    // This is the new line
    $this->get('event_dispatcher')->dispatch('meetup.join', new MeetupEvent($user, $meetup)
    );
    $em->flush();
}

All we did was find the event_dispatcher service and dispatch the meetup.join event associated with some data. Dispatching an event is nothing more than just sending a message under a name, meetup.join in our case, potentially with some data. Before the code keeps on executing to the next line, all the classes and objects that listen to that event will be given the opportunity to run some code as well.

Tip

It is a good practice to namespace your events to avoid event name collisions. The dot (.) notation is usually preferred to separate event namespaces. So, it's very common to find events such as acme.user.authentication.success, acme.user.authentication.fail, and so on.

Another good practice is to catalog and document your events. We can see that if we keep on adding many events, since they are so easy to trigger because it's only a name, we will have a hard time keeping track of what events we have and what their purpose is. It is even more important to catalog your events if you intend to share your code with other people at some point. To do that, we create a static events class as follows:

namespace Khepin\BookBundle\Event;

final class MeetupEvents
{
    /**
     * The meetup.join event is triggered every time a user
     * registers for a meetup.
     *
     * Listeners receive an instance of:
     * Khepin\BookBundle\Event\MeetupEvent
     */
    const MEETUP_JOIN = 'meetup.join';
}

As we said, this class is much more for documentation purposes than anything else. Your code can now be changed in the controller as follows:

$container->get('event_dispatcher')->dispatch(
    MeetupEvents::MEETUP_JOIN, 
    new MeetupEvent($user, $meetup)
);

We now know how to trigger an event, but we can't say that it has helped us to achieve anything interesting so far! Let's add a little bit of logic based on that. We will first create a listener class using the following code that will be responsible for generating the user's new list of preferred meetups:

namespace Khepin\BookBundle\Event\Listener;
use Khepin\BookBundle\Event\MeetupEvent;

class JoinMeetupListener
{
    public function generatePreferences(MeetupEvent $event) {
        $user = $event->getUser();
        $meetup = $event->getMeetup();
        // Logic to generate the user's new preferences
    }
}

Our class is a plain PHP class; it doesn't need to extend anything special. Therefore, it doesn't need to have any specific name. All it needs is to have at least one method that accepts a MeetupEvent argument. If we were to execute the code now, nothing would happen as we never said that this class should listen to a specific event. This is done by making this class a service again. This means that our listener could also be passed an instance of our geolocation service that we defined in the first part of this chapter, or any other existing Symfony service. The definition of our listener as a service, however, shows us some more advanced use of services:

join_meetup_listener:
    class: Khepin\BookBundle\Event\Listener\JoinMeetupListener
    tags:
        - { name: kernel.event_listener, event: meetup.join, method: generatePreferences }

What the tags section means is that when the event_dispatcher service is first created, it will also look for other services that were given a specific tag (kernel.event_listener in this case) and remember them. This is used by other Symfony components too, such as the form framework (which we'll see in Chapter 3, Forms).

Improving user performance

We have achieved something great by using events and listeners. All the logic related to calculating a user's meetup preferences is now isolated in its own listener class. We didn't detail the implementation of that logic, but we already know from this chapter that it would be a good idea to not keep it in the controller, but as an independent service that could be called from the listener. The more you use Symfony, the more this idea will seem clear and obvious; all the code that can be moved to a service should be moved to a service. Some Symfony core developers even advocate that controllers themselves should be services. Following this practice will make your code simpler and more testable.

Code that works after the response

Now, when our site grows in complexity and usage, our calculation of users' preferred event types could take quite a while. Maybe the users can now have friends on our site, and we want a user's choice to also affect his or her friend's preferences.

There are many cases in modern web applications where very long operations are not essential in order to return a response to the user. Some of the cases are as follows:

  • After uploading a video, a user shouldn't wait until the conversion of the video to another format is finished before seeing a page that tells him or her that the upload was successful

  • A few seconds could maybe be saved if we don't resize the user's profile picture before showing that the update went through

  • In our case, the user shouldn't wait until we have propagated to all his or her friends the news of him or her joining a meetup, to see that he or she is now accepted and taking part in the meetup

There are many ways to deal with such situations and to remove unnecessary work from the process of generating a response. You can use batch processes that will recalculate all user preferences every day, but this will cause a lag in response time as the updates will be only once a day, and can be a waste of resources. You can also use a setup with a message queue and workers, where the queue notifies the workers that they should do something. This is somewhat similar to what we just did with events, but the code taking care of the calculation will now run in a different process, or maybe even on a different machine. Also, we won't wait for it to complete in order to proceed.

Symfony offers a simple way to achieve this while keeping everything inside the framework. By listening to the kernel.terminate event, we can run our listener's method after the response has been sent to the client.

We will update our code to take advantage of this. Our new listener will now behave as explained in the following table:

Event

Listener

meetup.join

Remembers the user and meetup involved for later use. No calculation happens.

kernel.terminate

Actually generates the user preferences. The heavy calculation takes place.

Our code should then look as follows:

class JoinMeetupListener
{
    protected $event;

    public function onUserJoinsMeetup(MeetupEvent $event) {
        $this->event = $event;
    }

    public function generatePreferences() {
        if ($this->event) {
            // Generate the new preferences for the user
        }
    }
}

We then need to also update the configuration to call generatePreferences on the kernel.terminate event, as follows:

join_meetup_listener:
        class: Khepin\BookBundle\Event\Listener\JoinMeetupListener
        tags:
            - { name: kernel.event_listener, event: meetup.join, method: onUserJoinsMeetup }
            - { name: kernel.event_listener, event: kernel.terminate, method: generatePreferences }

This is done very simply by only adding a tag to our existing listener. If you were thinking about creating a new service of the same class but listening on a different event, you will have two different instances of the service. So, the service that remembered the event will never be called to generate the preferences, and the service called to generate the preferences will never have an event to work with. Through this new setup, our heavy calculation code is now out of the way for sending a response to the user, and he or she can now enjoy a faster browsing experience.