Symfony2, Tutorials

Leveraging the Symfony2 event dispatcher

Okay, so maybe you were paying attention when I told you to keep your Symfony2 controllers thin and you want to do a little housekeeping, but you’re not quite sure where to put all this extra code that you have lying around. For the sake of an example, let’s say that you had a messy controller that looked something like this (I apologize, it’s a little long, but I want to walk you through how you might clean it up over the course of a few posts):

[code language=”php”]
// FooVendor/BarBundle/Controller/ExampleController.php
public function addCommentAction($post_id)
{
$em = $this->container->get(‘doctrine’)->getEntityManager();
$request = $this->container->get(‘request’);

$post = $em->getRepository(‘FooVendorBundle:Post’)->find($post_id);
$comment = new Comment();
$comment->setPost($post);
$form = $this->createForm(new CommentType(), $comment);

if (‘POST’ === $request->getMethod()) {
$form->bindRequest($request);
if ($form->isValid()) {
$em->persist($comment);
$em->flush();

foreach ($post->getSubscribers() as $subscriber) {
$message = Swift_Message::newInstance()
->setSubject(‘New comment posted on ‘ . $post->getTitle())
->setFrom(‘send@example.com’)
->setTo($subscriber->getEmail())
->setBody("Hey, somebody left a new comment on a post you’re subscribed to! It says: " . $comment->getBody())
;
$this->get(‘mailer’)->send($message);
}

return $this->redirect(/* some redirect url */);
}
}

return $this->render(‘FooVendorBundle:Post:addComment.html.twig’, array(
‘form’ => $form->createView(),
));
}
[/code]

Basically all it’s doing is creating a new Comment entity on a blog post from user (form) input, saving it to the database, and then emailing an arbitrary list of subscribers about the new comment. It’s little bit of a contrived example, but it’ll do. There are a few things that can be done to clean up this controller, but I want to deal with the most glaring issue first: that nasty foreach loop that’s firing off emails. We’ll fix it after the cut.

If you’re going to clean up at all, you’ll need to move that. The most obvious thing you could do would be to move it to a service, then call it from the controller, which would look something like this:

[code language=”php”]
if ($form->isValid()) {
$em->persist($comment);
$em->flush();

// call a service that performs the same logic as before: email a list of post subscribers
$this->container->get(‘foo_bundle.subscriber_emailer’)->notifySubscribers($post, $comment);

return $this->redirect(/* some redirect url */);
}
[/code]

Not bad for a start; we condensed about ten lines of code into a single method call. But what if we decide somewhere down the road that we want to add more logic to, say, update an RSS feed of recent comments, or update a search index, or whatever else we might want? If we start tacking on one-liner service calls for every bit of functionality that we might need when a blog comment is posted, then pretty soon our controller starts to get out of hand again. That’s where the Symfony2 framework’s event system comes into play.

Adapted from the documentation, the event system works like this:

  1. A special kind of service called a listener tells a central dispatcher object that it wants to listen to one or more events (which are just what they sound like: something happening in your code);
  2. At some point, your code tells the dispatcher object to dispatch one of those events, passing with it an Event object containing information about the event;
  3. The dispatcher notifies (i.e. calls a method on) all listeners of the event, allowing each of them to act on it in turn

The framework is already handling the dispatcher; it’s our responsibility to write the listener and to dispatch the appropriate events. To do that, we’ll need to write classes to represent each of those:

[code language=”php”]
// FooVendor/BarBundle/Event/CommentEvent.php
use Symfony/Component/EventDispatcher/Event;

class CommentEvent extends Event
{
protected $post;
protected $comment;

public function __construct($post, $comment)
{
$this->post = $post;
$this->comment = $comment;
}

public function getPost()
{
return $this->post;
}

public function getComment()
{
return $this->comment;
}
}
[/code]

Nothing too fancy; the event just takes a post and a comment, and makes them available later via getPost() and getComment(). And here’s the listener:

[code language=”php”]
// FooVendor/BarBundle/EventListener/CommentListener.php
use FooVendorBarBundleEventCommentEvent;

class CommentListener
{
protected $mailer;

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

public function onCommentEvent(CommentEvent $event)
{
$post = $event->getPost();
$comment = $event->getComment();

foreach ($post->getSubscribers() as $subscriber) {
$message = Swift_Message::newInstance()
->setSubject(‘New comment posted on ‘ . $post->getTitle())
->setFrom(‘send@example.com’)
->setTo($subscriber->getEmail())
->setBody("Hey, somebody left a new comment on a post you’re subscribed to! It says: " . $comment->getBody())
;
$this->mailer->send($message);
}
}
}
[/code]

Our class just takes the mailer service as the dependency we’ll need to send out our emails later. And does the code in the onCommentEvent() method look familiar? It’s just the email notification code that we removed from the controller (yes, I’m aware that there are better ways to handle mass emails like this; that’s not the point of the exercise). As an aside, the listener class is in a namespace called EventListener. This is just the accepted convention: in practice, the listener class can live wherever you want.

Now that the listener is written, we need to declare it as a service. If you need a refresher on where the following configuration information goes, you can check out the docs.

[code language=”yaml”]
services:
foo_bundle.listener.comment:
class: FooVendorBarBundleEventListenerCommentListener
arguments:
mailer: "@mailer"
tags:
– { name: kernel.event_listener, event: foo_bundle.post.comment_added, method: onCommentEvent }
[/code]

Again, just a normal service declaration for the most part. The important bit is the part starting with tags… it’s telling the framework three things:

  1. to register your service as an event listener,
  2. that the listener service wants to receive notification of the foo_bundle.post.comment_added event (the name is arbitrary),
  3. when it is notified, that the onCommentEvent() method should receive the event.

Now let’s jump back to our controller and implement the code we’ve just written:

[code language=”php”]
if ($form->isValid()) {
$em->persist($comment);
$em->flush();

$dispatcher = $this->container->get(‘event_dispatcher’);
$dispatcher->dispatch(‘foo_bundle.post.comment_added’, new CommentEvent($post, $comment));

return $this->redirect(/* some redirect url */);
}
[/code]

Boom, we’re done. Every time a comment is added, a comment_added event containing all the relevant data (in this case, the post and the comment) will be fired off. Our comment listener will receive the event, and all the post’s subscribers will be notified by email, right on schedule. Even more powerfully, we’ve created an extension point that can be used again and again: next time we want to add some functionality that occurs every time a comment is added to a post, we can just create another listener to listen to the exact same foo_bundle.post.comment_added event, and it will Just Work, without needing to change the controller at all.

As a recap, here’s what our controller action looks like when we’re done:

[code language=”php”]
// FooVendor/BarBundle/Controller/ExampleController.php
use FooVendorBarBundleEventCommentEvent;

public function addCommentAction($post_id)
{
$em = $this->container->get(‘doctrine’)->getEntityManager();
$request = $this->container->get(‘request’);

$post = $em->getRepository(‘FooVendorBundle:Post’)->find($post_id);
$comment = new Comment();
$comment->setPost($post);
$form = $this->createForm(new CommentType(), $comment);

if (‘POST’ === $request->getMethod()) {
$form->bindRequest($request);
if ($form->isValid()) {
$em->persist($comment);
$em->flush();

$dispatcher = $this->container->get(‘event_dispatcher’);
$dispatcher->dispatch(‘foo_bundle.post.comment_added’, new CommentEvent($post, $comment));

return $this->redirect(/* some redirect url */);
}
}

return $this->render(‘FooVendorBundle:Post:addComment.html.twig’, array(
‘form’ => $form->createView(),
));
}
[/code]

It’s getting better, but there’s still a lot we can do to clean it up even more. Next time, we’ll talk about param converters and apply them to our example controller.

Further Reading on Symfony2

7 thoughts on “Leveraging the Symfony2 event dispatcher

  1. A more interesting question, how to clean up the final 20 lines? I have approx. 20 add actions and they look the same, except for entity and name field, and sometimes I copy-paste the code for a new action and feel wrong ;p

    1. So, I have been trying to figure this out. Python’s Django framework has something called generic views, which are pretty much the best answer to this problem that I’ve found… essentially, you skip the view (which equates to Symfony2’s controller action) and just declare a template to render and what kind of generic action should be taken (listing objects, creating new ones, etc). Pretty slick.

      As far as I know, Symfony2 doesn’t have anything like this yet, so maybe I’ll sit down and prototype a few ideas when I get some time.

  2. Hello,

    I have done exactly the same thing, but when trying, the Listener is never call because the listener defined in the bundle/resrouces/services.yml are loaded in the render process, so after the dispatch is called.
    Does someone go through the same problem ?
    any idea of how it can be solved ?
    thanks

  3. Great article. I am confused about 1 thing shouldn’t

    $dispatcher->dispatch(‘foo_bundle.post.comment_added’, new CommentEvent($post, $comment));

    Be

    $dispatcher->dispatch(‘foo_bundle.listener.comment’, new CommentEvent($post, $comment));

Leave a Reply