Loïc Faugeron Technical Blog

Master Symfony2 - part 5: Events 03/09/2014

Deprecated: This series has been re-written - see The Ultimate Developer Guide to Symfony

This is the fifth article of the series on mastering the Symfony2 framework. Have a look at the four first ones:

In the previous articles we created an API allowing us to submit and list quotes:

.
├── app
│   ├── AppKernel.php
│   ├── cache
│   │   └── .gitkeep
│   ├── config
│   │   ├── config_prod.yml
│   │   ├── config_test.yml
│   │   ├── config.yml
│   │   ├── doctrine.yml
│   │   ├── parameters.yml
│   │   ├── parameters.yml.dist
│   │   └── routing.yml
│   ├── logs
│   │   └── .gitkeep
│   └── phpunit.xml.dist
├── composer.json
├── composer.lock
├── src
│   └── Fortune
│       └── ApplicationBundle
│           ├── Controller
│           │   └── QuoteController.php
│           ├── DependencyInjection
│           │   └── FortuneApplicationExtension.php
│           ├── Entity
│           │   ├── QuoteFactory.php
│           │   ├── QuoteGateway.php
│           │   ├── Quote.php
│           │   └── QuoteRepository.php
│           ├── FortuneApplicationBundle.php
│           ├── Resources
│           │   └── config
│           │       ├── doctrine
│           │       │   └── Quote.orm.yml
│           │       └── services.xml
│           └── Tests
│               ├── Controller
│               │   └── QuoteControllerTest.php
│               └── Entity
│                   └── QuoteRepositoryTest.php
└── web
    └── app.php

Here's the repository where you can find the actual code.

In this one we'll learn how to extend the framework using events.

EventDispatcher Component

The Event Dispatcher is another standalone component which can be summed up as follow:

<?php

class EventDispatcher
{
    private $events = array();

    public function addListener($event, $listener)
    {
        $this->events[$event][] = $listener;
    }

    public function dispatch($event)
    {
        foreach ($this->events[$event] as $listener) {
            $listener();
        }
    }
}

You can register listeners (which are callables) and then call them by dispatching the subscribed event:

$dispatcher = new EventDispatcher();
$dispatcher->addListener('before.boyard', function () { echo 'Ultimate Challenge'; });
$dispatcher->dispatch('before.boyard'); // Prints "Ultimate Challenge".

Here's the actual API:

<?php

namespace Symfony\Component\EventDispatcher;

interface EventDispatcherInterface
{
    public function dispatch($eventName, Event $event = null);

    public function addListener($eventName, $listener, $priority = 0);
    public function removeListener($eventName, $listener);
    public function getListeners($eventName = null);
    public function hasListeners($eventName = null);

    public function addSubscriber(EventSubscriberInterface $subscriber);
    public function removeSubscriber(EventSubscriberInterface $subscriber);
}

The Component handles priorities, and contrary to our previous example it needs an Event object when dispatching events, allowing us to provide a context.

Subscribers are listeners which have a getSubscribedEvents method.

Note: If you want to learn more about this component, have a look at Raul Fraile's article.

In the fullstack framework

The Symfony2 HttpKernel Component dispatches events to provide extension points, we can:

Note: exceptions are caught by default, but this can be disabled.

Here's the full list of kernel events.

Note: If you want to learn more about those events, have a look at Matthias Noback's book: A year with Symfony.

The FrameworkBundle takes care of registering the listeners using the Dependency Injection Container (DIC): we declare our listener as a service in the configuration, with a specific tag.

Note: the DIC can retrieve all the services with the given tag using findTaggedServiceIds, making it easier to register listeners for example (this is done in Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass which is called in the FrameworkBundle).

Submitted JSON

In QuoteController::submitAction, we need to get the request's content and convert it from JSON. This is a generic task which should be executed before every controller: we can move it in an event listener.

First create the directory:

mkdir src/Fortune/ApplicationBundle/EventListener

Then we create the actual listener:

<?php
// File: src/Fortune/ApplicationBundle/EventListener/SubmitJsonListener.php

namespace Fortune\ApplicationBundle\EventListener;

use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class SubmitJsonListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        $content = $request->getContent();
        $data = json_decode($content, true);
        $request->request->add($data ?: array());
    }
}

Next we register it in the Dependency Injection Container:

<?xml version="1.0" ?>
<!-- File: src/Fortune/ApplicationBundle/Resources/config/services.xml -->

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="fortune_application.quote_factory"
            class="Fortune\ApplicationBundle\Entity\QuoteFactory"
        >
        </service>
        <service id="fortune_application.quote_gateway"
            class="Fortune\ApplicationBundle\Entity\QuoteGateway"
            factory-service="doctrine"
            factory-method="getRepository">
            <argument>FortuneApplicationBundle:Quote</argument>
        </service>
        <service id="fortune_application.quote_repository"
            class="Fortune\ApplicationBundle\Entity\QuoteRepository"
        >
            <argument type="service" id="fortune_application.quote_gateway" />
            <argument type="service" id="fortune_application.quote_factory" />
        </service>
        <service id="fortune_application.submit_json_listener"
            class="Fortune\ApplicationBundle\EventListener\SubmitJsonListener"
        >
            <tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" />
        </service>
    </services>
</container>

And finally we update the controller:

<?php
// File: src/Fortune/ApplicationBundle/Controller/QuoteController.php

namespace Fortune\ApplicationBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;

class QuoteController extends Controller
{
    public function submitAction(Request $request)
    {
        $postedValues = $request->request->all();
        if (empty($postedValues['content'])) {
            $answer = array('message' => 'Missing required parameter: content');

            return new JsonResponse($answer, Response::HTTP_UNPROCESSABLE_ENTITY);
        }
        $quoteRepository = $this->container->get('fortune_application.quote_repository');
        $quote = $quoteRepository->insert($postedValues['content']);

        return new JsonResponse($quote, Response::HTTP_CREATED);
    }

    public function listAction(Request $request)
    {
        $quoteRepository = $this->container->get('fortune_application.quote_repository');
        $quotes = $quoteRepository->findAll();

        return new JsonResponse($quotes, Response::HTTP_OK);
    }
}

We can now run the tests:

./vendor/bin/phpunit -c app

No regression detected! We can commit our work:

git add -A
git ci -m 'Used event'

Note: The FOSRestBundle provides such an event listener. We're only creating it manually here to learn about events.

Managing errors in a listener

If someone submits a malformed JSON, our listener can stop the execution and return a proper response:

<?php
// File: src/Fortune/ApplicationBundle/EventListener/SubmitJsonListener.php

namespace Fortune\ApplicationBundle\EventListener;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class SubmitJsonListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        $content = $request->getContent();
        $data = json_decode($content, true);
        if (JSON_ERROR_NONE !== json_last_error()) {
            $data = array('message' => 'Invalid or malformed JSON');
            $response = new JsonResponse($data, Response::HTTP_BAD_REQUEST);
            $event->setResponse($response);
            $event->stopPropagation();
        }
        $request->request->add($data ?: array());
    }
}

By setting a response in the event, the HttpKernel will almost stop (it dispatches a kernel.response event and an extra kernel.finish_request event) and return it.

By using stopPropagation, we prevent further kernel.request listeners from being executed.

Have a look at HttpKernel::handleRaw to discover what's going on.

Let's run the tests one last time:

./vendor/bin/phpunit -c app

All green, we can commit our work:

git add -A
git ci -m 'Handled errors'

Conclusion

Events are a powerful way to extend the framework: you create a listener, register it on a specific event and you're done.

Kernel events aren't the only ones available: Doctrine provides its own, (it uses its own event dispatcher library) the Symfony2 Form Component uses them and we could even create our own events!

The only drawback is that they're sort of hidden: by looking at the controller's code we cannot know that submitted JSON has been handled, we lose explicitness.

The next article will be about annotations.