Laminas Event Manager and Lazy listeners

Laminas MVC has an event driven architecture. This means that the flow of the application depends on triggered events in Laminas during HTTP request. The event triggering may carry additional information and usually passed to a specific event listeners. Having an event driven architecture allows the Laminas application to be loosely coupled and thus very extensible.

When working with events in Laminas it is important to realize and differentiate between MVC events and named events.

MVC events are triggered during every request and Laminas MVC triggers 7 events in the following order:

  1. MvcEvent::EVENT_BOOTSTRAP
  2. MvcEvent::EVENT_ROUTE
  3. MvcEvent::EVENT_DISPATCH
  4. MvcEvent::EVENT_DISPATCH_ERROR
  5. MvcEvent::EVENT_RENDER
  6. MvcEvent::EVENT_RENDER_ERROR
  7. MvcEvent::EVENT_FINISH

So for example during EVENT_ROUTE you can check from url what language version application should display, EVENT_DISPATCH is suitable for user authentication and role based access, EVENT_RENDER is good for layout initialization with default values (default title, language).

Example of setting translations during MVC event according to domain name

Module.php

<?php
namespace Application;

use Laminas\Mvc\ModuleRouteListener;
use Laminas\Mvc\MvcEvent;
use Laminas\Config;
use Laminas\Session;
use Application\Entity\I18n;

class Module
{

    const SESSION_CONTAINER = 'cont_name';

    public function onBootstrap(MvcEvent $event)
    {
        $application = $event->getApplication();
        $eventManager = $application->getEventManager();

        $eventManager->attach(MvcEvent::EVENT_ROUTE, array(
            $this,
            'onPreRoute'
        ), 10);
    }

    public function getConfig()
    {
        $config = new Config\Config(include __DIR__ . '/../config/module.config.php');
        $router = Config\Factory::fromFile(__DIR__ . '/../config/router.ini', true);
        $config->merge($router);

        return $config;
    }

    public function onPreRoute(MvcEvent $event)
    {
        $application = $event->getApplication();
        $services = $application->getServiceManager();
        $config = $services->get('Config');

        $request = $application->getMvcEvent()->getRequest();
        $host = $request->getHeaders()->get('Host');
        list($realHost,) = explode(':', $host->getFieldValue()); 

        $domain = $config['web']['domain_lang'];
        $language = $domain[$realHost];

        $session = new Session\Container(self::SESSION_CONTAINER);
        $session->offsetSet('language', $language);

        $translator = $services->get(\Laminas\Mvc\I18n\Translator::class);
        $router = $services->get('Router');

        if ($language == I18n::LANG_DE) {
            $translator->setLocale(I18n::LOCALE_DE);
            $translator->addTranslationFile('phparray', __DIR__ . '/../locale/de_DE.php', 'default', I18n::LOCALE_DE);
            $translator->addTranslationFile('gettext', __DIR__ . '/../locale/de_DE.mo', 'default', I18n::LOCALE_DE);
            $router->setTranslator($translator);
        } elseif ($language == I18n::LANG_EN) {
            $translator->setLocale(I18n::LOCALE_EN);
            $translator->addTranslationFile('phparray', __DIR__ . '/../locale/en_US.php', 'default', I18n::LOCALE_EN);
            $translator->addTranslationFile('gettext', __DIR__ . '/../locale/en_US.mo', 'default', I18n::LOCALE_EN);
            $router->setTranslator($translator);
         } else {
            $translator->setLocale(I18n::LOCALE_CS);
        }
    }
}

Note: Better way for adding translation files is in global or module config files (https://docs.laminas.dev/laminas-i18n/translator/factory/#setting-translation-files).

Triggering own events, Shared Event Manager

On the other side, named events are executed only when they are triggered. But a listener decides if the event will be executed. With this logic you have lots of options how to implement specific requirements in application. For example user action logging, exception logging etc.

Triggering event ‘log’ with parameters priority and message in a controller action:

try {
    ...
 } catch (\Exception $e) {
    $this->getEventManager()->trigger('log', $this,
                    array(
                        'priority' => Logger::ERR,
                        'message' => __METHOD__ . '->' . $e->getMessage()
                    ));
    $this->flashMessenger()->addErrorMessage('Error was reported to administrators.');
}

One of the most common places where we will register listeners for listening to events in our modules is the onBootstrap method in the Module.php. There we have access to EventManager where we can get the SharedManager. Listening to named events requires us to have access the Shared Event Manager. The EventManager is actually only for MVC events.

Lets display implementation of logging user actions (user deleted record, inserted data into database etc.). Implementation consist of:

  • EventLogListener.php
  • EventLogListenerFactory.php (Both located in Event directory.)
  • Module.php (register listener)
  • module.config.php (Register listener as service with factory class.)

EventLogListener.php

<?php
namespace User\Event;

use Laminas\EventManager\ListenerAggregateInterface;
use Laminas\EventManager\EventManagerInterface;
use Laminas\EventManager\EventInterface;
use Laminas\EventManager\ListenerAggregateTrait;
use User\Model\UsersActionsLogTable;

class EventLogListener implements ListenerAggregateInterface
{

    use ListenerAggregateTrait;

    public $modelUsersActionsLog;

    public function __construct(UsersActionsLogTable $modelUsersActionsLog)
    {
        $this->modelUsersActionsLog = $modelUsersActionsLog;
    }

    public function attach(EventManagerInterface $events, $priority = - 100)
    {
        $sharedManager = $events->getSharedManager();

        $this->listeners[] = $sharedManager->attach('*', 'logUserAction', array(
            $this,
            'onLogUserAction'
        ), $priority);
    }

    public function onLogUserAction(EventInterface $event)
    {
        $this->modelUsersActionsLog->logUserAction($event);
        // code for store event parameters in logUserAction() method is omitted
    }
}

EventLogListenerFactory.php

<?php
namespace User\Event;

use Laminas\ServiceManager\Factory\FactoryInterface;
use Interop\Container\ContainerInterface;
use User\Model\UsersActionsLogTable;

class EventLogListenerFactory implements FactoryInterface
{

    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        return new $requestedName($container->get(UsersActionsLogTable::class));
    }
}

Module.php

<?php
namespace User;

use Laminas\Mvc\MvcEvent;
use Laminas\Config;

class Module
{

    public function onBootstrap(MvcEvent $event)
    {
        $application = $event->getApplication();
        $eventManager = $application->getEventManager();

        // register listener
        $listener = $application->getServiceManager()->get(Event\EventLogListener::class);
        $listener->attach($eventManager);
    }

    public function getConfig()
    {
        $config = new Config\Config(include __DIR__ . '/../config/module.config.php');
        $router = Config\Factory::fromFile(__DIR__ . '/../config/router.ini', true);
        $config->merge($router);

        return $config;
    }

 }

module.config.php

<?php
namespace User;

return array(
    'controllers' => array(
        'factories' => array(
            Controller\IndexController::class => Controller\IndexControllerFactory::class
        )
    ),
    'service_manager' => array(
        'factories' => array(
            Event\EventLogListener::class => Event\EventLogListenerFactory::class
        )
    ),
   ...
);

Example of triggering event in controller action, or where EvenManager is accesible.


$this->getEventManager()->trigger('logUserAction', $this,
            array(
                // params to log to table
            ));

In Zend Framework version 3.0 (currently Laminas), lazy listener was introduced. The purpose is to reduce the performance overhead of fetching listeners from Service Manager until they are triggered. This will help minimize the number of objects pulled from dependency injection container. So when you have a listener for event which is not required on every request it is wise to register listener as lazy listener.

Lazy listener example

For example, under certain circumstances application should stop display discount price at specific item. And there could be more than one place in the application where the circumstances occures.

So rather than inject dependencies into a controllers where circumstances might occur, it is preferable to trigger event at specific position. The whole process of hiding discount is isolated in the lazy listener then.

Implementation is similar to previous example of EventLogListener only the listener class is not implementing ListenerAggregateInterface. Registering listener is different.

HideDiscountListener.php

<?php
namespace Order\Event;

use Laminas\ServiceManager\ServiceLocatorInterface;
use Laminas\EventManager\EventInterface;

class HideDiscountListener
{

    public $serviceManager;

    public function __construct(ServiceLocatorInterface $serviceManager)
    {
        $this->serviceManager = $serviceManager;
    }

    public function onHideDiscount(EventInterface $event)
    {
        $param = $event->getParam('param');
      
	// hide discount logic	 
 	...
    }
}

HideDiscountListenerFactory.php and module.config.php has similar code such a previous example.

Module.php

<?php
namespace Order;

use Laminas\Config;
use Laminas\Mvc\MvcEvent;
use Laminas\EventManager\LazyListener;

class Module
{

    public function onBootstrap(MvcEvent $event)
    {
        $application = $event->getApplication();
        $eventManager = $application->getEventManager();
        $serviceManager = $application->getServiceManager();

        // register Lazy Listener
        $definition = array(
          'listener' => Event\HideDiscountListener::class,
          'method' => 'onHideDiscount'
        );
        $sharedEventManager = $eventManager->getSharedManager();
        $sharedEventManager->attach(Service\OrderItem::class, 'hideDiscount', new
          LazyListener($definition, $serviceManager), - 100);
	$sharedEventManager->attach(Controller\OrderController::class, 'hideDiscount', new
          LazyListener($definition, $serviceManager), - 100);
    }
    ...
}

Note: I could attach listener to instance \Laminas\Mvc\Controller\AbstractController and have only one attach method. But I would lose control where trigger is actually placed. So with this solution I know, that the event hideDiscount will be accepted by listener only if it’s triggered from class OrderItem and OrderController.

Footnotes

  1. Laminas MvcEvent
  2. Laminas EventManager
  3. Logging in Zend Framework Using the Event Manager