Laminas ServiceManager: Trust Boundaries and Operational Guards in a Modular Monolith
An architecture note on using Laminas ServiceManager as a composition root: defining trust boundaries between modules, operational guards for predictable object graphs, and failure modes that change the design.
06 Aug 2026, 21:52 UTC

Requirements: A Predictable Composition Root
Laminas applications rely on ServiceManager (a PSR‑11 container) as the single place where object graphs are wired. The requirements are:
- Configuration must be inspectable without booting MVC.
- Modules developed by different teams must not silently override each other's services.
- Bootstrap latency must stay low, even with dozens of services.
- Misconfiguration (missing env vars, wrong module order) must surface at deploy time, not at first user request.
Smallest Suitable Design: Configuration‑Only Wiring
Each module returns a getServiceConfig() array with keys factories, abstract_factories, initializers, delegators, and aliases. The container merges these arrays in module load order — later modules win. This merge is the explicit trust boundary: a module author cannot assume their factory will be used unless they control load order or use a unique service name.
// module/Api/src/Module.php
public function getServiceConfig(): array
{
return [
'factories' => [
ApiClient::class => ApiClientFactory::class,
],
'delegators' => [
ApiClient::class => [LoggingDelegator::class],
],
];
}
Trust and Data Boundaries
Module Isolation
Because merging is last‑writer‑wins, two modules registering factories[DbAdapter::class] create a silent override. Laminas 3.4+ mitigates this with ConfigAbstractFactory::setConfigKey('my_module'), which namespaces configuration so teams share a container without colliding.
Lazy Services as a Boundary Tool
LazyServiceFactory wraps a service in a proxy that instantiates on first method call. This breaks circular‑reference cycles and reduces bootstrap cost, but it pushes missing‑dependency errors to the first call site. Critical services (database adapter, command bus) should be eagerly resolved in a health‑check endpoint.
Delegators and Initializers Ordering
Delegators wrap service creation (AOP‑style) and run in registration order. An initializer that injects a logger must run before a delegator that expects the logger to be present. Document the required order in the module's README.
Operational Checks
Pre‑Deploy Diagnostics
Run the CLI diagnostic to dump the merged configuration and spot duplicates or missing factories:
vendor/bin/laminas-diagnose
This command (from laminas-cli) prints the final service map — run it in CI before promotion.
Startup Health‑Check Route
Add a route that eagerly resolves a curated list of critical services:
// config/routes.php
return [
'router' => [
'routes' => [
'health' => [
'type' => Literal::class,
'options' => [
'route' => '/health/startup',
'defaults' => [
'controller' => HealthController::class,
'action' => 'startup',
],
],
],
],
],
];
// src/Controller/HealthController.php
public function startupAction()
{
$critical = [
DatabaseAdapter::class,
CommandBus::class,
EventDispatcher::class,
];
foreach ($critical as $id) {
try {
$this->getServiceLocator()->get($id);
} catch (\Throwable $e) {
return new JsonModel(['status' => 'down', 'service' => $id, 'error' => $e->getMessage()]);
}
}
return new JsonModel(['status' => 'up']);
}
Production Config Caching
Enable config_cache_enabled in config/autoload/global.php. The cached array becomes the single source of truth; diff it against staging to catch drift.
// config/autoload/global.php
return [
'config_cache_enabled' => true,
'config_cache_key' => 'laminas-config',
];
Failure Modes and Mitigations
| Failure Mode | Symptom | Mitigation |
|---|---|---|
| Abstract factory typo | Mis‑configured object, no exception | Prefer explicit factories for domain services; reserve abstract factories for infrastructure adapters with stable schemas. |
| Circular dependency | ServiceNotCreatedException at resolution |
Refactor to events or a mediator; avoid ServiceManager::get('self') in factories. |
| Expensive initializer | Bootstrap latency spikes | Keep initializers pure and fast — no DB connections or remote calls. |
| Request‑scoped state in shared service | Data leaks between requests | Mark service shared: false or use a custom delegator that returns a new instance per request. |
| Missing env var in factory | ServiceNotFoundException in production |
Map container exceptions to 503 with correlation IDs; treat as deployment‑time misconfiguration. |
Conditions That Would Change the Design
- Adoption of a compiled container (e.g., laminas‑servicemanager 4.x generating a PHP class) would shift validation to build time and eliminate runtime merge order surprises.
- Migration to a request‑scoped container (PSR‑11 with scoped bindings) would replace the
shared: falsepattern with explicit scopes. - Policy‑driven configuration (e.g., OPA rules on service definitions) could enforce naming namespaces and forbid abstract factories for domain code.
Verification Checklist
- Run
vendor/bin/laminas-diagnosein CI; fail on duplicate keys or missing factories. - Assert in a unit test that
ServiceManager::get(YourService::class)returns the expected concrete class and that all constructor arguments are satisfied (useReflectionClassto verify no optional dependencies are accidentally null). - Deploy with config caching enabled; diff the cached array between staging and production.
- Monitor the
/health/startupendpoint; alert on any 503 response.
These practices keep the composition root predictable, the trust boundaries visible, and the operational surface small enough to reason about in a modular monolith.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.