laminas/laminas-servicemanager
Laminas ServiceManager is a PSR-11 compatible dependency injection container for PHP. It supports factory-based service creation, aliases, abstract factories, delegators, and lazy loading, making it a flexible foundation for managing object dependencies.
Fit assessment up front. laminas/laminas-servicemanager is a PSR-11 service locator with a factory-driven configuration model. Laravel already ships Illuminate\Container\Container, which does the same job with auto-wiring, contextual binding, and a mature set of app()->singleton()/bind() helpers. There are zero Laravel packages depending on this library, so treat it as a deliberate, scoped choice, not a default. The realistic reasons a Laravel developer reaches for it are:
laminas-form, laminas-validator, laminas-db, laminas-hydrator) whose internals are built around AbstractPluginManager and Laminas\ServiceManager\ServiceManager.module.config.php dependency wiring intact while the rest of the app is Laravel.If none of those apply, stop — use Laravel's container.
Install and minimal bootstrap.
composer require laminas/laminas-servicemanager
use Laminas\ServiceManager\ServiceManager;
use Laminas\ServiceManager\Factory\InvokableFactory;
$container = new ServiceManager([
'factories' => [
\App\Support\Clock::class => InvokableFactory::class,
],
]);
$clock = $container->get(\App\Support\Clock::class); // shared
$other = $container->build(\App\Support\Clock::class); // fresh instance
Three config keys carry most of the weight: factories, aliases, and delegators. Everything else (abstract_factories, initializers, shared, shared_by_default) is refinement.
Where to look first, in order.
| Doc | Why you care |
|---|---|
docs/book/v3/quick-start.md |
Constructor array, get() vs build(), sharing semantics. |
docs/book/v3/configuring-the-service-manager.md |
The full config key reference; read this before inventing your own. |
docs/book/v3/factories.md |
The FactoryInterface::__invoke($container, $requestedName, $options) contract. |
docs/book/v3/delegators.md |
The closest analogue to Laravel's decorator/tap patterns. |
docs/book/v3/plugin-managers.md |
Only if you're integrating Laminas components — this is the shape they arrive in. |
docs/book/v3/cookbook/factories-vs-abstract-factories.md |
Performance vs. convenience tradeoff; explains why explicit factories win. |
First concrete use case for a Laravel app. Bridge the two containers in one service provider and never look back:
// app/Providers/LaminasServiceProvider.php
use Laminas\ServiceManager\ServiceManager;
use Laminas\Validator\ValidatorPluginManager;
public function register(): void
{
$this->app->singleton(ServiceManager::class, function ($app) {
return new ServiceManager([
'factories' => [
// Laminas-side factory delegating back to Laravel
\App\Domain\InvoiceRepository::class
=> fn () => $app->make(\App\Domain\InvoiceRepository::class),
],
'aliases' => [
'InvoiceRepository' => \App\Domain\InvoiceRepository::class,
],
]);
});
$this->app->singleton(ValidatorPluginManager::class, function ($app) {
return new ValidatorPluginManager($app->make(ServiceManager::class), [
'factories' => [
\Laminas\Validator\StringLength::class => \Laminas\ServiceManager\Factory\InvokableFactory::class,
],
]);
});
}
Register the Laminas manager as a singleton and treat it as a leaf dependency. Do not let it become a second application container.
1. Factories are the unit of wiring. Every service is produced by a callable with a fixed signature:
final class ReportFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): Report
{
return new Report(
$container->get(ReportRepository::class),
$options['format'] ?? 'pdf',
);
}
}
InvokableFactory::class is shorthand for "no-arg construct via new". For anything with dependencies, write a real factory class — it is the only form that survives config caching (see Gotchas).
2. Mirror Laravel's container with a thin adapter layer. When both containers are in play, pick one direction and stay consistent. The pragmatic rule:
$app->make(...).ReportRepository.'factories' => [
\Laminas\Db\Adapter\Adapter::class => function ($sm) {
$cfg = app('config')->get('database.connections.' . config('database.default'));
return new \Laminas\Db\Adapter\Adapter([
'driver' => 'Pdo_Mysql',
'hostname' => $cfg['host'],
'username' => $cfg['username'],
'password' => $cfg['password'],
'database' => $cfg['database'],
]);
},
],
3. Use ConfigAbstractFactory to kill boilerplate, then graduate. This is the Laminas equivalent of Laravel's auto-wiring, and it's the sweet spot when mapping a dozen classes:
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
'dependencies' => [
'factories' => [
Report::class => ConfigAbstractFactory::class,
],
],
'config_abstract_factory' => [
Report::class => [
ReportRepository::class, // resolved from the container
'reports.default_format', // or a config key
],
],
ReflectionBasedAbstractFactory goes further: it reflects the constructor and resolves every type-hinted parameter, with zero per-service config. Excellent for a first pass during a migration; terrible as a permanent production strategy because every get() loops all abstract factories (see the factories-vs-abstract-factories cookbook). The documented evolution — reflection "catch-all" → ConfigAbstractFactory → generated explicit factories via vendor/bin/generate-factory-for-class — is genuinely good advice and maps directly onto how you'd migrate a legacy module incrementally.
4. Delegators are your decoration/middleware seam. A delegator wraps the factory callback and returns the service, so you can inject logging, caching, or proxying without touching the factory:
use Laminas\ServiceManager\Factory\DelegatorFactoryInterface;
final class LoggingDelegator implements DelegatorFactoryInterface
{
public function __invoke(ContainerInterface $container, string $name, callable $callback, ?array $options = null): object
{
$service = $callback();
return new TimedDecorator($service, $container->get(LoggerInterface::class));
}
}
// config
'delegators' => [
InvoiceRepository::class => [
LoggingDelegator::class,
CachingDelegator::class, // applied in array order, outermost last
],
],
This is the pattern to reuse most heavily — it's how you add cross-cutting concerns to services baked inside a Laminas component you don't control.
5. Lazy services for expensive dependencies. laminas-servicemanager ships LazyServiceFactory/LazyServiceAggregator that build ProxyManager proxies, so a DB connection or mailer that is injected into many services is only actually constructed on first method call. Setup requires friendsofphp/proxy-manager-lts and registering the aggregator as a delegator:
'delegators' => [
\Doctrine\DBAL\Connection::class => [
\Laminas\ServiceManager\Proxy\LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [\Doctrine\DBAL\Connection::class => \Doctrine\DBAL\Connection::class],
],
Use this in Laravel only where you have measured a boot-cost problem (typically long-running Octane or queue workers with many injected clients). Otherwise the extra dependency and proxy indirection isn't worth it.
6. Plugin managers when integrating Laminas components. A plugin manager is a nested container with instance validation:
final class FilterPluginManager extends AbstractPluginManager
{
protected $instanceOf = \Laminas\Filter\FilterInterface::class;
}
Register it as a service in the parent container, and inside its factories remember the v3 rule: the $container passed to a plugin factory is the parent service manager, not the plugin manager. Fetch the plugin manager from it if you need sibling plugins:
function ($container, $name, array $options = []) {
$filters = $container->get(FilterPluginManager::class);
// ...
}
7. Runtime configuration for tests. ServiceManager exposes mutators that make it trivially swappable in feature tests, which is often nicer than fighting Laravel's container:
$sm = new ServiceManager($config);
$sm->setService(Mailer::class, $fakeMailer); // pre-built instance wins over any factory
$sm->setFactory(Clock::class, fn () => new FrozenClock('2026-01-01'));
$sm->configure(['aliases' => ['clock' => Clock::class]]);
Laravel's config:cache will explode on closures. Laravel serialises config with var_export, so any Closure inside a config array is uncacheable. If your Laminas config lives in config/laminas.php, the factory entries must be class-strings (ReportFactory::class), never fn ($sm) => .... Register closures imperatively in a service provider instead, and keep the config file purely declarative. This is the single most common way a working dev setup breaks in production.
Two containers means two singletons for the same class. If ReportRepository is bound in Laravel as a singleton and also registered in Laminas with its own factory, you get two instances with divergent state. Always bridge by delegation (fn () => app(Repo::class)) rather than re-instantiating. Conversely, do not call app() from inside a Laminas factory at a point where the app hasn't finished booting — you'll get a partially-wired instance and a very confusing bug. Resolve lazily, inside the closure body, not at registration time.
Don't inject the container into your services. ServiceManager happily lets you do $container->get() from anywhere; that's how you end up with a service locator anti-pattern that no static analyser can trace. The factory signature gives you the container for free, so the temptation is real. Restrict $container to factory bodies only.
invokables was removed in v3. Old ZF2 config that says 'invokables' => [...] will silently(ish) fail. Convert to 'factories' => ['Name' => InvokableFactory::class].
Shared by default. Every get() returns the same instance. If you need a discrete object (a request-scoped DTO, a per-call validator), use build(). You can flip the global default with 'shared_by_default' => false and then opt individual services back in via 'shared' => [Name::class => true] — but this is a big behavioural switch and rarely correct outside specialised plugin managers.
Plugin manager get() throws InvalidServiceException, not ServiceNotFoundException, when a factory returns the wrong type. If you're asserting on exceptions in tests, catch the right one. Also note $instanceOf validation is skipped for setService()? — no, it is enforced; but it is skipped when the service is retrieved via an alias pointing to an already-validated entry.
Circular dependencies surface as CircularDependencyFoundException, and the message names the cycle. The usual cause in a mixed setup is a Laminas factory that calls app() which resolves something that calls back into Laminas. The fix is almost always to move the dependency behind a delegator or a lazy proxy.
Aliases don't cascade. 'aliases' => ['a' => 'b', 'b' => C::class] is not a reliable chain — alias resolution is meant to be a single hop to a real service name. Keep aliases flat.
Performance: prefer explicit factories, but measure before you refactor. Abstract factories cost a hash lookup plus 1:N canCreate() invocations per get(). With a handful of abstract factories this is noise; with fifteen it isn't. benchmarks/ and vendor/bin/phpbench run --report=aggregate in the package repo let you quantify it, and the console tools (generate-deps-for-config-factory, generate-factory-for-class) let you convert cheaply. Note also that resolution cost is per-service-first-request when services are shared, so the real cost concentrates at boot — which matters a lot under Octane.
Version and namespace hygiene. This is the maintained successor to zendframework/zend-servicemanager; if you're porting old code, rewrite Zend\ServiceManager\* → Laminas\ServiceManager\*. Current major versions require modern PHP (8.1+), so check compatibility before adding it to an older Laravel app. Also confirm which major you installed: v3 and v4 differ in the factory $options parameter nullability and in some deprecations around AbstractPluginManager.
Debugging recipe. When a service won't resolve, work down this list: (1) is the requested name an alias pointing at a real entry? (2) does a factory exist under factories for the resolved name? (3) if relying on an abstract factory, does canCreate() actually return true for that name — call it directly to check? (4) is a delegator swallowing or replacing the instance? Wrap resolution in a try/catch on Laminas\ServiceManager\Exception\ServiceNotCreatedException and inspect getPrevious() — the underlying cause (missing dependency, constructor type error) is almost always there.
Extension points worth knowing. AbstractPluginManager (nested containers with type validation), DelegatorFactoryInterface (decoration), InitializerInterface (post-construction initialize($instance, $container) hook — useful for injecting a logger into every service implementing a marker interface), AbstractFactoryInterface (convention-based resolution), and the mutable ServiceManager::configure()/setFactory()/setService() API (test doubles). The initializer is the one Laravel developers underuse — it's the closest thing to a global "booted model" hook.
How can I help you explore Laravel packages today?