So, you've opened a Zend Framework 2 codebase for the first time.

You scroll to Module.php, see a method called getServiceConfig(), and find an array with keys like invokables, factories, abstract_factories, aliases, delegators, initializers. Half the project's wiring lives in there. None of it is obvious. And every time you try to read a controller, you end up chasing a service name through three files just to find the constructor that actually runs.

Welcome to the ServiceManager.

It's the spine of every ZF2 app, the dependency injection container that decides how every controller, service, view helper, form element, and plugin gets built. Once it clicks, the framework starts to make sense. Until then, it feels like reading wiring diagrams in a language nobody quite documented.

This article is the long version of that click. We'll go through every config key, every factory style, every interface, and every gotcha, in detail, with code. The ZF2 version, with notes for the ZF 2.5+ / ZF3 / Laminas API where it changed.

Let's break it down.

What The ServiceManager Actually Is

The ServiceManager is a dependency-injection container. You hand it a name, it hands you back a fully-constructed object with all its dependencies wired in.

PHP
$logger  = $container->get('Application\Log\Logger');
$mailer  = $container->get('App\Mail\Mailer');
$report  = $container->get('Reporting\Service\WeeklyReport');

Three lines. Three objects. Each one might have five dependencies. Each of those might have five more. You don't care, you ask for the top of the tree, and the container builds the whole thing.

That's the easy half. The hard half is how it builds them. Modern PHP containers (PHP-DI, Symfony's container with autowiring) try to figure dependencies out by reading type-hints. The ZF2 ServiceManager doesn't. It does explicit wiring: you tell it, for every service, exactly how to build it. Verbose, yes. Magical, no. Once a junior on the team gets lost, you can grep one filename and find the answer.

The ServiceManager itself is Zend\ServiceManager\ServiceManager in ZF2 (and Laminas\ServiceManager\ServiceManager after the Laminas fork). It implements Zend\ServiceManager\ServiceLocatorInterface, and from ZF 2.5+ also Interop\Container\ContainerInterface (PSR-11's ancestor).

You configure it through one big array, usually merged from many modules. That array has a fixed set of keys.

The Seven Keys You'll See

Open any ZF2 module's module.config.php or Module::getServiceConfig() and you'll see some subset of these:

PHP
return [
    'service_manager' => [
        'services'           => [...],
        'invokables'         => [...],
        'factories'          => [...],
        'abstract_factories' => [...],
        'aliases'            => [...],
        'shared'             => [...],
        'shared_by_default'  => true,
        'delegators'         => [...],
        'initializers'       => [...],
    ],
];

Each one answers a different question:

  • services: I already built this object, just hand it out.
  • invokables: Build this by calling new SomeClass() with no arguments.
  • factories: I'll give you a factory class or callable that builds it.
  • abstract_factories: If nothing else matched, ask these to figure it out.
  • aliases: This name is just another name for that name.
  • shared / shared_by_default: Reuse the instance, or build a fresh one every time?
  • delegators: Wrap the built service before returning it.
  • initializers: Run this callable on every newly-built service.

That's the whole vocabulary. Eight keys, and one of them is a boolean. Once you internalize what each one does, every ZF2 codebase reads the same way.

Now let's go through them one at a time.

Dark-theme flowchart showing how the ZF2 ServiceManager resolves a service name: six decision diamonds (registered service, alias, invokables, factories, any abstract_factory canCreate, otherwise-throw) with yes/no branches, plus three right-side panels for Delegators wrap the instance, Initializers run on the instance, and Cache if shared

Invokables: The Simplest Case

invokables is the absolute simplest mapping the container has. It's a map of service name to class name, and the rule is: when somebody asks for the service, do new ClassName(). No arguments, no setup, just instantiate.

PHP
'invokables' => [
    'App\Service\HelloWorld'      => \App\Service\HelloWorld::class,
    'App\Service\TextFormatter'   => \App\Service\TextFormatter::class,
],

Then anywhere in the app:

PHP
$hello = $container->get('App\Service\HelloWorld');
$hello->greet('Nazar');

That's it. No constructor parameters. No dependencies pulled from the container. If the class has a constructor that takes anything, you can't use invokables: the container won't pass anything in.

Some teams use invokables for almost everything, dependencies and all, by letting classes pull what they need from $container later, through ServiceLocatorAwareInterface or by injecting the container. Don't. That's the anti-pattern we'll get to in a few sections. If a class has dependencies, register it as a factory instead.

There's one more detail. In ZF 2.5+, invokables got a small upgrade: the framework auto-registers a factory called InvokableFactory behind the scenes, so the two configs below are equivalent:

PHP
'invokables' => [
    'App\Service\HelloWorld' => \App\Service\HelloWorld::class,
],

// equivalent in ZF 2.5+:
'factories' => [
    'App\Service\HelloWorld' => \Zend\ServiceManager\Factory\InvokableFactory::class,
],

You'll see both forms in real codebases. They do the same thing.

Factories: Where The Real Work Happens

The moment a service has even one dependency, you graduate to factories. A factory is a piece of code that knows how to build the service and pull its dependencies out of the container.

PHP
'factories' => [
    'App\Mail\Mailer' => \App\Mail\MailerFactory::class,
],

A factory can be three things:

  1. A class implementing FactoryInterface: the standard, reusable form.
  2. A Closure or any callable: quick and inline.
  3. A class name with __invoke: modern, also satisfies FactoryInterface in v3+.

The interface itself changed between ZF2 v2 and ZF 2.5+. This is the single most confusing thing about reading old ZF2 code in 2022, so let's look at both.

The Old Style: createService()

In ZF2 v2.0-2.4, factories looked like this:

PHP
namespace App\Mail;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class MailerFactory implements FactoryInterface
{
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $config = $serviceLocator->get('Config');
        $logger = $serviceLocator->get('Application\Log\Logger');

        return new Mailer(
            $config['mail']['smtp_host'],
            $config['mail']['smtp_port'],
            $logger
        );
    }
}

One method, one argument, returns the service. The $serviceLocator is the ServiceManager itself, so you can pull whatever else you need.

This worked but had a problem: the factory couldn't easily know which service was being asked for, which made it hard to write one factory that could build several similar services.

The New Style: __invoke()

In ZF 2.5+, the new signature is:

PHP
namespace App\Mail;

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

class MailerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $config = $container->get('Config');
        $logger = $container->get('Application\Log\Logger');

        return new Mailer(
            $config['mail']['smtp_host'],
            $config['mail']['smtp_port'],
            $logger
        );
    }
}

Three differences worth noting:

  • ContainerInterface instead of ServiceLocatorInterface. The framework is now PSR-11-friendly.
  • $requestedName is passed in. One factory can now build different services depending on what was asked for.
  • $options can carry per-call options when you use $container->build($name, $options) (a v3+ feature, separate from get).

In Laminas (post-2019), the namespaces became Laminas\ServiceManager\Factory\FactoryInterface and Psr\Container\ContainerInterface. The shape is identical.

For new code, prefer the __invoke style. The framework happily accepts both. Under the hood, ZF 2.5+ wraps old-style factories in an adapter that calls createService() for you.

Closures: When A Class Is Overkill

For one-line factories, a closure in config is fine:

PHP
'factories' => [
    'App\Mail\Mailer' => function (ContainerInterface $container) {
        $config = $container->get('Config');
        return new \App\Mail\Mailer(
            $config['mail']['smtp_host'],
            $config['mail']['smtp_port']
        );
    },
],

Closures are tempting because they're inline. They're also a trap when the config gets cached (and ZF2's ModuleManager does cache merged config in production). Closures aren't serializable. If a closure ends up in a cached config array, the cache build crashes. Use closures only in dev-time configs you don't cache, or stay with factory classes. Factory classes survive any cache layer because they're just class names.

One Factory, Multiple Services

Because $requestedName is passed in, you can register the same factory for several names:

PHP
'factories' => [
    'App\Repo\UserRepository'    => \App\Repo\RepositoryFactory::class,
    'App\Repo\OrderRepository'   => \App\Repo\RepositoryFactory::class,
    'App\Repo\InvoiceRepository' => \App\Repo\RepositoryFactory::class,
],
PHP
class RepositoryFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $entityName = str_replace('App\\Repo\\', 'App\\Entity\\', $requestedName);
        $entityName = str_replace('Repository', '', $entityName);

        $em = $container->get('Doctrine\ORM\EntityManager');
        return new $requestedName($em->getRepository($entityName));
    }
}

One factory, three services. The factory reads the requested name and figures out which entity to wire up.

This is also where abstract factories start to look appealing, and they're next.

Abstract Factories: Catching The Unknown

An abstract factory is a fallback. The container tries services, then invokables, then factories. If none of those match, it loops over the registered abstract factories and asks each one: "can you build this?"

PHP
'abstract_factories' => [
    \App\Repo\RepositoryAbstractFactory::class,
],

The interface has two methods:

PHP
namespace App\Repo;

use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\AbstractFactoryInterface;

class RepositoryAbstractFactory implements AbstractFactoryInterface
{
    public function canCreate(ContainerInterface $container, $requestedName)
    {
        return str_starts_with($requestedName, 'App\\Repo\\')
            && str_ends_with($requestedName, 'Repository')
            && class_exists($requestedName);
    }

    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $em = $container->get('Doctrine\ORM\EntityManager');
        $entity = str_replace(['App\\Repo\\', 'Repository'], ['App\\Entity\\', ''], $requestedName);

        return new $requestedName($em->getRepository($entity));
    }
}

Now any time anyone asks for App\Repo\AnythingRepository, the abstract factory catches it, validates it, and builds it. You don't have to register each one.

In ZF2 v2.0-2.4, the same interface had different method names:

PHP
// old (v2.0–2.4)
public function canCreateServiceWithName(ServiceLocatorInterface $sl, $name, $requestedName);
public function createServiceWithName(ServiceLocatorInterface $sl, $name, $requestedName);

In v2.5+, they became canCreate and __invoke. If you're reading older code, that's the rename to remember.

A few real abstract factories that ship with the framework or are common in real apps:

  • ConfigAbstractFactory (ZF 2.5+): reads a \Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory::class config block where you list each service's dependencies, then builds them generically.
  • ReflectionBasedAbstractFactory (ZF 2.5+): uses PHP reflection on the constructor signature to autowire dependencies from the container. The closest thing ZF2 has to Symfony-style autowiring.

If you've been wishing ZF2 had autowiring, ReflectionBasedAbstractFactory is the answer, though older projects often don't have it wired up.

Aliases: Names For Names

aliases is a simple lookup: when someone asks for 'A', give them 'B'.

PHP
'aliases' => [
    'logger'          => 'Application\Log\Logger',
    'App\Log\Logger'  => 'Application\Log\Logger',
    'mail.transport'  => 'App\Mail\SmtpTransport',
],

Three uses:

  1. Short names. 'logger' is friendlier than 'Application\Log\Logger'.
  2. Migration glide path. You renamed a service from App\Log\Logger to Application\Log\Logger: leaving the old name as an alias keeps old code working while you migrate.
  3. Swapping implementations. Production aliases mail.transport to App\Mail\SmtpTransport. Tests alias it to App\Mail\InMemoryTransport. No code change in the consumers.

Aliases can chain (a → b → c), but the resolution is one-pass: make sure the final target actually resolves to something the container can build.

Pre-Built Services

services is the escape hatch for objects you've already constructed:

PHP
'service_manager' => [
    'services' => [
        'Application\Config\AppVersion' => '2.4.1',
        'Application\Time\Clock'        => new SystemClock(),
    ],
],

The container stores them as-is and hands them out on demand. Useful when:

  • You're integrating a third-party SDK that already returns a configured client.
  • You want to register a primitive (string, int, array) so other factories can pull it.
  • You have an instance from the bootstrap that should be reused.

It bypasses every factory mechanism. Once a name is in services, the container will never try to build it.

Shared, And shared_by_default

By default, the ServiceManager shares instances. Ask for 'logger' ten times, get the same Logger ten times. That's what you want most of the time. Services are usually stateless, and rebuilding them every call wastes work.

But not always. Sometimes you want a fresh instance every time: a per-request form object, a per-job command, a stateful entity that shouldn't carry over.

PHP
'service_manager' => [
    'shared_by_default' => true,         // global default
    'shared' => [
        'App\Form\OrderForm'    => false, // always fresh
        'App\Command\SendEmail' => false,
        'App\Mail\Mailer'       => true,  // explicit, even though it's the default
    ],
],

Set shared_by_default to false and the rule flips: everything is fresh unless you mark it true. Most apps leave it true and use the per-service shared map for exceptions.

There's also $container->build($name, $options) in ZF 2.5+, which always returns a fresh instance regardless of the shared setting. Use it when you want a one-off without changing the global config.

Delegators: The Decorator Hook

A delegator wraps a service after it's built but before the container hands it out. It's the ServiceManager's built-in decorator pattern.

PHP
'delegators' => [
    'App\Mail\Mailer' => [
        \App\Mail\LoggingMailerDelegator::class,
        \App\Mail\RetryingMailerDelegator::class,
    ],
],

Each delegator factory receives a callable that produces the "inner" service:

PHP
namespace App\Mail;

use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\DelegatorFactoryInterface;

class LoggingMailerDelegator implements DelegatorFactoryInterface
{
    public function __invoke(
        ContainerInterface $container,
        $name,
        callable $callback,
        array $options = null
    ) {
        $inner  = $callback();
        $logger = $container->get('Application\Log\Logger');

        return new LoggingMailer($inner, $logger);
    }
}

$callback() builds (or returns the previous delegator's wrapped instance of) the underlying service. You wrap it, return the wrapper, and the next delegator in the array gets your wrapper as its inner. Stack them and you get an onion: RetryingMailer(LoggingMailer(RealMailer)).

Delegators are powerful for cross-cutting concerns like logging, retries, caching, and profiling, without modifying the inner service or copying the factory. They're also the right answer to "how do I add behavior to a third-party service?". You can't edit it; you can wrap it.

The pre-v2.5 interface was createDelegatorWithName($sl, $name, $requestedName, $callback). Same idea, longer signature.

Initializers: The Wildcard (And Why You Should Avoid Them)

An initializer is a callable that runs on every single service the container builds. Every one.

PHP
'initializers' => [
    \App\Service\EventManagerInitializer::class,
],
PHP
namespace App\Service;

use Interop\Container\ContainerInterface;
use Zend\EventManager\EventManagerAwareInterface;

class EventManagerInitializer
{
    public function __invoke(ContainerInterface $container, $instance)
    {
        if ($instance instanceof EventManagerAwareInterface) {
            $instance->setEventManager($container->get('EventManager'));
        }
    }
}

The pattern is: check if the freshly built object implements some marker interface, and if so, inject a dependency setter-style.

This is how ZF2 v2 wired controllers to the service locator. Every controller implementing ServiceLocatorAwareInterface got the container injected via an initializer. Every controller, every request.

It also brings two costs:

  1. Performance. Initializers run on every build. With a deep service tree and a few initializers, you're doing dozens of instanceof checks per request. Cheap individually, not cheap in aggregate.
  2. Hidden wiring. A new developer reading a service constructor sees zero dependencies. They have no way to know an initializer is mutating the object after construction unless they go looking. Implicit injection is one of the harder things to debug.

In ZF3, the controller initializer was removed and the framework forced everyone onto constructor injection. That was the right call. Today, treat initializers as a last resort for legacy interop. If you're starting fresh, inject through the factory and skip them.

ServiceLocatorAwareInterface: The Famous Anti-Pattern

While we're here, the elephant in the ZF2 room.

PHP
namespace Zend\ServiceManager;

interface ServiceLocatorAwareInterface
{
    public function setServiceLocator(ServiceLocatorInterface $serviceLocator);
    public function getServiceLocator();
}

In ZF2 v2, controllers automatically received the service locator via an initializer. That meant any controller method could do this:

PHP
public function indexAction()
{
    $logger  = $this->getServiceLocator()->get('logger');
    $mailer  = $this->getServiceLocator()->get('App\Mail\Mailer');
    $config  = $this->getServiceLocator()->get('Config');
    // ...
}

Convenient. It also turned the container into a junk drawer. Dependencies disappeared from the constructor. You couldn't tell what a class actually needed without reading every method. Tests had to mock the entire container.

In v3, the framework removed ServiceLocatorAwareInterface support for controllers. Today the right shape is:

PHP
namespace App\Controller;

use Zend\Mvc\Controller\AbstractActionController;

class OrderController extends AbstractActionController
{
    public function __construct(
        private readonly \App\Service\OrderService $orders,
        private readonly \Application\Log\LoggerInterface $logger
    ) {}

    public function placeAction()
    {
        $this->logger->info('Placing order');
        // ...
    }
}

Wired through a controller factory:

PHP
class OrderControllerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        return new OrderController(
            $container->get('App\Service\OrderService'),
            $container->get('Application\Log\Logger')
        );
    }
}

If you're reading an old ZF2 codebase: getServiceLocator() calls inside controllers are tech debt. They're not wrong (they still work in v2-style apps), but they're the first thing to clean up when you have time. Convert each one to a constructor dependency in a factory.

Plugin Managers: Not Quite The Same Container

ZF2 doesn't use one giant ServiceManager for everything. It uses plugin managers, specialized child containers for specific kinds of services. The main ones:

  • Zend\Mvc\Controller\ControllerManager: manages controllers, configured under controllers in module config.
  • Zend\Mvc\Controller\PluginManager: manages controller plugins ($this->params(), $this->redirect()), configured under controller_plugins.
  • Zend\View\HelperPluginManager: manages view helpers, configured under view_helpers.
  • Zend\Form\FormElementManager: manages form elements, configured under form_elements.
  • Zend\Filter\FilterPluginManager, Zend\Validator\ValidatorPluginManager, etc.

They all extend AbstractPluginManager, which extends ServiceManager. So the config shape is the same: factories, invokables, aliases, the lot, but registered under their own top-level key:

PHP
return [
    'service_manager' => [
        'factories' => [
            'App\Service\OrderService' => \App\Service\OrderServiceFactory::class,
        ],
    ],
    'controllers' => [
        'factories' => [
            \App\Controller\OrderController::class => \App\Controller\OrderControllerFactory::class,
        ],
    ],
    'view_helpers' => [
        'invokables' => [
            'formatMoney' => \App\View\Helper\FormatMoney::class,
        ],
    ],
    'form_elements' => [
        'factories' => [
            \App\Form\OrderForm::class => \App\Form\OrderFormFactory::class,
        ],
    ],
];

The reason they're separate containers is that each one validates the type of thing it returns. ControllerManager checks that whatever you registered is actually a controller-like object. FormElementManager checks that what comes out is a form element. If you accidentally register App\Service\OrderService under controllers, you'll get a clear error when the framework tries to dispatch to it.

It's a small bit of type safety in a framework that predates property types.

How A $container->get() Call Actually Resolves

Putting it all together, here's the order the ServiceManager walks when you call $container->get('SomeName'):

  1. services: already registered? Return it. Done.
  2. aliases: is the name an alias? Resolve to the target and recurse from step 1.
  3. invokables: registered? Call new ClassName().
  4. factories: registered? Call the factory.
  5. abstract_factories: for each one (in registration order), call canCreate(). First one that returns true gets to build.
  6. Otherwise, throw Zend\ServiceManager\Exception\ServiceNotFoundException.

Then, regardless of which branch built it:

  1. Delegators: if any are registered for this service, wrap in order.
  2. Initializers: every initializer runs on the (wrapped) instance.
  3. Cache: if the service is shared, store the result so future get() calls return the same object.

That's the entire algorithm. Once you have it in your head, debugging "where does this service come from?" becomes a grep checklist instead of a guessing game.

Lazy Services: The Optional Power Move

ZF 2.5+ added support for lazy services via Zend\ServiceManager\Proxy\LazyServiceFactory (built on top of ocramius/proxy-manager). The idea: register a service as lazy, and the container returns a transparent proxy. The real service isn't actually built until you call a method on it.

PHP
'lazy_services' => [
    'class_map' => [
        'App\Mail\Mailer' => \App\Mail\Mailer::class,
    ],
],
'delegators' => [
    'App\Mail\Mailer' => [
        \Zend\ServiceManager\Proxy\LazyServiceFactory::class,
    ],
],

The proxy is a generated subclass of Mailer that overrides every public method. Call something on it, like $mailer->send(...), and it builds the real Mailer on the spot and forwards the call.

Reach for this when a service is expensive to build (opens a DB connection, parses a 200KB config) and might not be used on every request. Skip it for everything else. Proxies aren't free, and the indirection makes stack traces uglier.

Reading An Unfamiliar ZF2 Codebase

If you're new to a ZF2 project, here's the order I open files in:

  1. config/application.config.php: lists active modules.
  2. Each Module.php: look for getServiceConfig(), getControllerConfig(), getViewHelperConfig(), etc. These are the dynamic configs.
  3. Each module's config/module.config.php: the static config arrays. Same keys.

The framework merges all of them into one big config tree. The ServiceManager you eventually call is configured from the service_manager slice of that tree, and every plugin manager is configured from its own slice (controllers, view_helpers, ...).

When a service name shows up in code and you don't know where it's defined, grep:

Bash
rg "'App\\Mail\\Mailer'" --type=php

You'll usually land in the factory map of one specific module. From there, open the factory class, read its __invoke (or createService), and follow the dependencies.

That single discipline, every service can be traced to one file in one module, is what makes ZF2 hold up in large codebases. The verbosity isn't a bug; it's the price of "no magic, no surprises."

A Compact Mental Model

If you compress all of it into one paragraph:

The ServiceManager is a map from service names to "build instructions." Build instructions come in four flavors: pre-built object, just new it, call this factory, or let an abstract factory figure it out. Aliases let one name point to another. Delegators wrap the result. Initializers run on every result. Shared services are cached after first build. Plugin managers are the same thing again, scoped to controllers, view helpers, form elements, and so on. Everything ZF2 dispatches goes through this machinery, every single request.

Once that paragraph lives in your head, the wiring stops being mysterious. You stop reading Module.php like it's a foreign language and start reading it like a configuration form: which key is this service in, and what does that key mean?

That's the whole trick. The ServiceManager doesn't reward shortcuts, but it rewards patience, and the second time you trace a dependency from controller to factory to constructor with no surprises, the design choice starts to make sense.