Wiring Services in Laminas ServiceManager v3: Factories, Delegators, and Common Pitfalls
Learn to configure Laminas ServiceManager v3 with factories and delegators, avoid circular dependencies, and understand configuration merging quirks in modular applications.
28 Jan 2026, 20:54 UTC

The Problem: Service Wiring in Modular Laminas Applications
Laminas MVC applications rely on Laminas\ServiceManager\ServiceManager (v3.x) as the core dependency injection container. Developers often struggle to wire services correctly across modules — especially when factories, delegators, and abstract factories interact with configuration merging and the separate ControllerManager. This article explains the mechanism with a worked configuration, then covers limits and common mistakes so you can avoid runtime errors and silent misconfigurations.
How ServiceManager Resolves Services
ServiceManager resolves services by name from a merged configuration array under the service_manager key. The merged configuration comes from every module's getServiceConfig() (or module.config.php) and is combined by ModuleManager in the order listed in application.config.php — later modules override earlier ones. Key configuration sections:
- factories: map a service name (class or string) to a factory callable or class implementing
FactoryInterface. - aliases: map an alias to a canonical service name.
- shared: boolean per service (default
true) —falsecreates a new instance perget()call (prototype scope). - delegators: map a service name to a list of delegator callables that wrap the created instance.
- abstract_factories: list of classes implementing
AbstractFactoryInterfaceused as fallback when no concrete factory exists.
A factory receives (ContainerInterface $container, string $requestedName, array|null $options = null): mixed. The container passes itself so factories can pull other services (database adapters, config, etc.) and inject them into the created service.
Worked Example: Registering a Service with a Factory
Assume a service App\Service\UserRepository that needs a database adapter. Create the service class:
Create a factory App\Service\UserRepositoryFactory:
get(AdapterInterface::class);
return new UserRepository($db);
}
}
Register the factory in module/Application/config/module.config.php:
[
'factories' => [
UserRepository::class => UserRepositoryFactory::class,
],
// optional: ensure prototype scope if needed
'shared' => [
UserRepository::class => false,
],
],
];
Now inject the service into a controller. Create a controller factory App\Controller\UserControllerFactory:
get(UserRepository::class);
return new UserController($repo);
}
}
Register the controller factory under controllers → factories in the same module.config.php:
'controllers' => [
'factories' => [
UserController::class => UserControllerFactory::class,
],
],
The controller receives the repository via constructor injection:
repo */ }
}
Adding Cross-Cutting Behavior with Delegators
Delegators wrap an already-created service to add cross-cutting concerns (logging, caching, transactions) without modifying the original factory. Each delegator receives (ContainerInterface $container, string $name, callable $callback): mixed and must return the wrapped instance. Example: a logging delegator for UserRepository.
get(LoggerInterface::class);
return new class($repo, $logger) implements UserRepository {
public function __construct(private UserRepository $inner, private LoggerInterface $log) {}
public function findById(int $id): ?array {
$this->log->info('Finding user', ['id' => $id]);
return $this->inner->findById($id);
}
};
}
}
Register it in module.config.php:
'delegators' => [
UserRepository::class => [
UserRepositoryLoggingDelegator::class,
],
],
When $container->get(UserRepository::class) runs, the factory creates the instance, then each delegator wraps it in order. Delegators run even for shared services (the same wrapped instance is cached).
Abstract Factories for Generic Services
Abstract factories act as a catch-all for services not explicitly configured. They implement AbstractFactoryInterface with canCreate(ContainerInterface, string): bool and __invoke(ContainerInterface, string, array|null): mixed. Use them for generic repositories or adapter plugins. Example: a generic DoctrineRepositoryAbstractFactory that creates repositories for any entity class.
get(EntityManagerInterface::class);
return $em->getRepository($requestedName);
}
}
Register under abstract_factories:
'abstract_factories' => [
DoctrineRepositoryAbstractFactory::class,
],
Caution: Abstract factories run after concrete factories. A misconfigured canCreate() returning true too broadly can silently override intended services. Keep canCreate() specific.
Configuration Merging and Module Order
Configuration merging is not deep for arrays under factories, delegators, or abstract_factories. Later modules replace the entire sub-array, not merge keys. If ModuleA defines 'factories' => [A::class => FactoryA::class] and ModuleB defines 'factories' => [B::class => FactoryB::class], the merged result contains only B's entry. To add additively, use a compile-time config aggregation step or the extends pattern (manual merge in a later module's getServiceConfig()). Module order in application.config.php determines override precedence.
Controller Injection: ControllerManager vs Application Container
Laminas\Mvc\Controller\ControllerManager extends ServiceManager but is a separate container. Services registered only under top-level service_manager are not automatically available in controller factories. The controller factory must pull dependencies from the main application container. In v3, the controller factory receives the ControllerManager as $container. Access the main container via $container->getServiceLocator() (deprecated) or, better, inject the main container explicitly by registering the controller factory under the application service_manager and using a delegator — but the standard practice is to let the controller factory retrieve services from the main container via $container->getServiceLocator() (v2) or by configuring the controller factory to be created by the application container. The simplest reliable approach: register controller factories under controllers → factories and have them call $container->getServiceLocator()->get(Service::class) (v2) or, in v3, ensure the controller factory is invoked with the application container by using ServiceManager as the controller manager's parent — this is handled automatically in the MVC skeleton. Verify by checking that $this->getServiceLocator() inside a controller returns the application container.
Common Mistakes and Limitations
- Invokables removed in v3:
invokablesconfiguration is gone. Migrate to factories returningnew ClassName()to avoidServiceNotCreatedException. - Shared vs prototype: Default
shared => truereturns the same instance. Settingshared => falsecreates a new instance perget(), but delegators may not re-run per instance if not configured carefully — test prototype scope with delegators. - Circular dependencies: Throw
ServiceNotCreatedException. Resolve by refactoring to lazy services (Laminas\ServiceManager\LazyServiceFactory) or injecting the container and pulling the dependency lazily. - Abstract factory overreach: Broad
canCreate()silently overrides concrete factories. Keep it specific. - Config merging not deep: Later modules replace entire
factoriesordelegatorsarrays. Plan additive changes via compile-time aggregation. - ControllerManager separation: Services in application
service_managernot auto-available in controller factories. Use$container->getServiceLocator()(v2) or ensure controller factory has access to main container.
Verification Steps
- Create a minimal skeleton: Run
composer create-project laminas/laminas-mvc-skeleton my-app(requires write permission in target directory). Add the service, factory, and controller factory undermodule/Application/src/. - Register in module.config.php as shown above.
- Test retrieval: In a controller action, call
$repo = $this->repo;(injected) and verify it works. Alternatively, dump merged config:var_dump($this->getServiceLocator()->get('Config')['service_manager'])(v2) or attach a delegator on a known service to inspect registered entries. - Run the laminas-servicemanager test suite:
vendor/bin/phpunitin the package directory to confirm factory, delegator, and abstract factory behaviors match documented version. - Consult official docs: docs.laminas.dev for current ServiceManager reference and v2→v3 migration notes.
These steps verify that your wiring works as expected and that configuration merging behaves predictably.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.