Using Monolog with Zend Service Manager

Without any doubt, Monolog and Zend Service Manager are two libraries that are almost always found in the composer.json file require section of my projects. In case you didn't know, Monolog is a PSR-3 compliant logging library that allows you to save logs to various storage types and web services, while Zend Service Manager is a PSR-11 compliant dependency injection container and a service locator implementation that facilitates management of application dependencies.

In this post I'm gonna show you how the two can work together.

Configuration

Preferred way of configuring Zend Service Manager is using an associative array containing definitions of services. Typically, this configuration lives in a file, along with other configurations of your application.

Monolog is based on a concept of creating Logger instances, whereas each one is identified by a channel (name) and equipped with a stack of handlers. And just like a router, database handler, cache, or any other application dependency of that kind, Logger is a service that you register with the DI container and inject it into other application services.

Here's a basic example of a Logger service usage:

config.php

use Interop\Container\ContainerInterface;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\FirePHPHandler;
use Monolog\Logger;

return [
    'dependencies' => [
        'factories' => [
            'AppLogger' => function (ContainerInterface $container) {
                $logger = new Logger('app');

                $logger->pushHandler(new StreamHandler(__DIR__ . '/../data/log/app.log', Logger::DEBUG));
                $logger->pushHandler(new FirePHPHandler());

                return $logger;
            },
        ],
    ],
];

index.php

use Zend\ServiceManager\ServiceManager;

$config = require 'config.php';
$serviceManager = new ServiceManager($config['dependencies']);

$logger = $serviceManager->get('AppLogger');
$logger->info('Hello world');

Separate configuration and object construction logic

While this is all that it takes to glue Monolog and Zend Service Manager together, configuration itself looks bulky and can become hard to maintain as you add more loggers. I prefer my configuration files to be light, plain PHP arrays, and keep object construction logic in separate classes. Besides a callable, Zend Service Manager also supports specifying factories as class names, so let's change our example accordingly:

config.php

return [
    'dependencies' => [
        'factories' => [
            'AppLogger' => My\AppLoggerFactory::class,
        ],
    ],
];

AppLoggerFactory.php

namespace My;

use Interop\Container\ContainerInterface;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\FirePHPHandler;
use Monolog\Logger;

class AppLoggerFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $logger = new Logger('app');

        $logger->pushHandler(new StreamHandler(__DIR__ . '/../data/log/app.log', Logger::DEBUG));
        $logger->pushHandler(new FirePHPHandler());

        return $logger;
    }
}

index.php

use Zend\ServiceManager\ServiceManager;

$config = require 'config.php';
$serviceManager = new ServiceManager($config['dependencies']);

$logger = $serviceManager->get('AppLogger');
$logger->info('Hello world');

Keep environment-specific configuration away from code

By solving one problem, we've introduced another one. Our code now contains something that varies between environments (development, staging, production). In this particular example it is a path to the log file (data/log/app.log), but this includes everything that is specific for the environment on which application is running. Things like database connection parameters, credentials for external services, and as we now know logging configuration, should not be kept in code and put under version control by any cost! Note that configuration file with real values should not be versioned neither, but only as a template, usually named config.php.dist by a convention.

Bearing all this mind, let's apply appropriate changes:

config.php

return [
    'logger' => [
        'app' => [
            'file' => __DIR__ . '/../data/log/app.log',
        ],
    ],
    'dependencies' => [
        'factories' => [
            'AppLogger' => My\AppLoggerFactory::class,
        ],
    ],
];

AppLoggerFactory.php

namespace My;

use Interop\Container\ContainerInterface;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\FirePHPHandler;
use Monolog\Logger;

class AppLoggerFactory
{
    public function __invoke(ContainerInterface $container)
    {
        $config = $container->get('Config');

        $logger = new Logger('app');

        $logger->pushHandler(new StreamHandler($config['logger']['app']['file'], Logger::DEBUG));
        $logger->pushHandler(new FirePHPHandler());

        return $logger;
    }
}

index.php

$config = require 'config.php';

$serviceManager = new ServiceManager($config['dependencies']);
$serviceManager->set('Config', $config);

$logger = $serviceManager->get('AppLogger');
$logger->info('Hello world');

Two things to note here:

  1. Additional service was registered with the Service Manager - 'Config', which holds entire configuration array.
  2. Logger factory retrieves configuration from the provided Container (Service Manager) instance to obtain environment-specific logger configuration.

Generic logger factory

As you add more and more loggers, therefore writing more factories, you start thinking about a generic factory that can instantiate loggers based on their array-like configuration, containing handlers, formatter and processors definitions.

Zend Service Manager facilitates such an idea in particular by featuring a concept of mapping multiple services to the same factory. This was made possible through $requestedName that is passed as the second parameter of a factory.

To give you an idea of how a generic logger factory may look like:

namespace My;

use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;

final class LoggerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $config = $container->get('Config');
        $loggerConfig = $config['logger'];

        if (! array_key_exists($requestedName, $loggerConfig)) {
            throw new ServiceNotFoundException(sprintf(
                'Configuration for "%s" is missing',
                $requestedName
            ));
        }

        return $this->createLoggerFromConfig($loggerConfig[$requestedName]);
    }

    private function createLoggerFromConfig(array $config)
    {
        // ...
    }
}

Configuration file becomes a lot more cleaner and readable:

return [
    'logger' => [
        'AppLogger' => [
            'name' => 'app',
            'handlers' => [
                [
                    'name' => Monolog\Handler\StreamHandler::class,
                    'options' => [
                        'stream' => __DIR__ . '/../data/log/app.log',
                        'level' => Monolog\Logger::INFO,
                    ],
                ],
                [
                    'name' => Monolog\Handler\FirePHPHandler::class,
                ],
            ],
        ],
        'NotificationsLogger' => [
            'name' => 'notifications',
            [
                'name' => Monolog\Handler\LogglyHandler::class,
                'options' => [
                    'token' => '123',
                ],
            ],
        ],
    ],
    'dependencies' => [
        'factories' => [
            'AppLogger' => My\LoggerFactory::class,
            'NotificationsLogger' => My\LoggerFactory::class,
        ],
    ],
];

Monolog Factory

After repeating these things through projects, I eventually wrote a generic logger factory whose essence I've omitted in the previous example, with the aim of letting you know at this point that I've made Monolog Factory - library that facilitates creation of Monolog logger objects in both generic and container-interop contexts. It works nicely with Zend Service Manager, but also any other PSR-11 compliant dependency injection container.


zf monolog zend-service-manager