Events, the EventManager and ListenerAggregates in Zend Framework 2

There are a bunch of good articles on this subject on the web already, but for my own reference I put together this article listing the steps to take to set up your own custom event listeners in your ZF2 application.

Start by creating a class handling the events. This class will implement the Zend\EventManager\ListenerAggregateInterface. In this example I will create a listener aggregate that takes care of sending out e-mails for different events.

I like to keep my listener aggregates to be groupings of related actions in response to events. For example I would have a LogEventsListener listening to the ‘runtime_error’ event. The handler for this event would for instance make a log entry of what went wrong. At the same time I would have a MailEventsListener also listening to the ‘runtime_error’ event and send out an e-mail to the developer or administrator when a fatal error occurs in the application (random example). Besides the ‘runtime_error’ event there could be other events that either the LogEventsListener or MailEventsListener deal with.

So back to the code the MailEventsListener could look like this.

MyApp\Listener\MailEventsListener.php:

 

<?php
namespace MyApp\Listener;

use Zend\EventManager\EventInterface;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\ServiceManager\ServiceLocatorAwareInterface;

use MyApp\Config\ConfigAwareTrait;

use MtMail\Service\Mail as MailService;

class MailEventsListener implements ListenerAggregateInterface,ServiceLocatorAwareInterface
{
    use ConfigAwareTrait;

    /**
     * @var MailService
     */
    protected $mailService;
    
    /**
     * @var \Zend\Stdlib\CallbackHandler[]
     */
    protected $listeners = array();

    /**
     * {@inheritDoc}
     */
    public function attach(EventManagerInterface $events)
    {
        $sharedEvents = $events->getSharedManager();
        $this->listeners[] = $sharedEvents->attach(
                                        '*', 
                                        'runtime_error', 
                                        array($this, 'onRuntimeError'), 
                                        100
        );
    }
    
    public function detach(EventManagerInterface $events)
    {
        foreach ($this->listeners as $index => $listener)
        {
            if ($events->detach($listener))
            {
                unset($this->listeners[$index]);
            }
        }
    }
    
    /**
     * @return \MtMail\Service\Mail
     */
    public function getMailService()
    {
        if (!$this->mailService)
        {
            $this->mailService = $this->getServiceLocator()->get('MtMail\Service\Mail');
        }
         
        return $this->mailService;
    }
    
    /**
     * @param \MtMail\Service\Mail $mailService
     */
    public function setMailService(\MtMail\Service\Mail $mailService)
    {
        $this->mailService = $mailService;
    }
    
    public function onRuntimeError(EventInterface $e)
    {
        /* @var $mailService \MtMail\Service\Mail */
        $mailService = $this->getMailService();
        
        $config = $this->getConfig();
        $emailConfig = $config['email'];

        // ... code to create mail message omitted ...

        $message = $mailService->compose($headers, 'mailtemplate/error-alert', $templateValues);
        $mailService->send($message);
    }
}

In this case I set the identifier to ‘*’ which means that the listener gets notified about every ‘runtime_error’ event, no matter who triggers the event.
When you want to be more specific and you know for instance that only a specific service triggers a certain event then just set it to that identifier, i.e.:

$this->listeners[] = $sharedEvents->attach(
                                        'MyApp\Service\ServiceTriggeringRuntimeErrorEvents', 
                                        'runtime_error', 
                                        array($this, 'onRuntimeError'), 
                                        100
        );

I also want to be able to easily turn listeners on and off and add new listeners by using the config. At first I wrote some bootstrap code for that.
Module.php:

public function onBootstrap(MvcEvent $e)
{
    $eventManager = $e->getApplication()->getEventManager();
    $sm = $e->getApplication()->getServiceManager();
    $config = $sm->get('config');

    if (array_key_exists('listeners', $config))
    {
        $listeners = $config['listeners'];
        foreach ($userListeners as $curListener)
        {
            $listener = $sm->get($curListener);
            $eventManager->attach($listener);
        }
    }
}

However as it turns out Zend Framework 2 has a nice undocumented feature, which takes care of attaching listeners that you list in the ‘listeners’ key of the application’s config. I found out about this because I happened to use the same key name ‘listeners’ to register my listeners. 🙂

So we can (and should) leave out the extra bootstrap code and just let the framework take care of registering the listeners. Just add this to any autoloaded config file. I personally use application.config.php for this, because I consider it to be an application setting.
application.config.php:

return array(
    'listeners' => array(
        'MyApp\Listener\LogEventsListener',
        'MyApp\Listener\MailEventsListener',
    ),
    
    'modules' => array(
// etc.

For the service locator to be able to load the listener object you still need to add a service_manager entry to your module.config.php.

module.config.php:

'service_manager' => array(
        'invokables' => array(
            'MyApp\Listener\LogEventsListener' => 'MyApp\Listener\LogEventsListener',
            'MyApp\Listener\MailEventsListener' => 'MyApp\Listener\MailEventsListener',
        ),
// etc.

To be able to trigger events the triggering class has to implement the Zend\EventManager\EventManagerAwareInterface. This makes sure the EventManager gets injected when a new instance of your class is being created. The easiest way is to use the Zend\EventMaanager\EventManagerAwareTrait, which implements the methods required by the interface:

namespace MyApp\Service;

use Zend\EventManager\EventManagerAwareTrait;
use Zend\EventManager\EventManagerAwareInterface;

class MyService implements EventManagerAwareInterface
{
    use EventManagerAwareTrait;
...

Then trigger the event from the service like this:

public function testTriggerEvent()
{
    $this->getEventManager()->trigger('runtime_error', $this, array('param1' => 'value1'));
}

Update (23/07/2014)

After a tip I received I decided to change the listener aggregate classes to implement the SharedListenerAggregateInterface interface instead. This means the aggregate receives the shared eventmanager and thus you don’t have to call $events->getSharedManager() anymore. It requires some small changes to the aggregate class:

MyApp\Listener\MailEventsListener.php:

<?php
namespace MyApp\Listener;

use Zend\EventManager\EventInterface;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\SharedListenerAggregateInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\ServiceManager\ServiceLocatorAwareInterface;

use MyApp\Config\ConfigAwareTrait;

use MtMail\Service\Mail as MailService;

class MailEventsListener implements SharedListenerAggregateInterface,ServiceLocatorAwareInterface
{
...
    public function attachShared(SharedEventManagerInterface $events)
    {
        $this->listeners[] = $events->attach(
                                         'Zend\Mvc\Controller\AbstractActionController', 
                                         'runtime_error', 
                                         array($this, 'onLog'), 
                                         100
         );
    }


    public function detachShared(SharedEventManagerInterface $events)
    {

...

Problem is when I try to have the listeners attached automatically through the ‘listeners’ config key, as described in the original blog post above. I’m receiving an exception when I use that so instead I re-added my own custom code in onBootstrap of my application’s main module. I use a different key for this too: ‘aggregate_listeners’ and moved them to module.config.php of the application’s main module.