Architecture Note: Using Laminas ServiceManager as a PHP DI Container
A concise architecture note on using Laminas ServiceManager as a DI container: requirements, minimal factory‑based design, clear trust boundaries, validation steps, typical failure modes, and signals for redesign.
21 Sept 2025, 18:31 UTC

Requirements
The application needs a dependency‑injection container that can resolve services lazily, support shared and non‑shared lifetimes, and keep configuration separate from business logic. The team prefers explicit factories over implicit auto‑wiring to make trust boundaries visible.
Smallest Suitable Design
Use a single Laminas\ServiceManager\ServiceManager instance configured with:
- Factories – either invokable callbacks or factory classes implementing
Laminas\ServiceManager\Factory\FactoryInterface. - Shared services marked
shared => true(default) for stateless objects. - Non‑shared services marked
shared => falsefor request‑scoped objects (e.g., DB connections in long‑running workers). - No abstract factories or initializers unless a genuine cross‑cutting rule (such as applying a common logger) exists.
Example configuration (config/container.php):
<?php
return [
'services' => [
'factories' => [
Model\UserRepository::class => Factory\UserRepositoryFactory::class,
Service\Mailer::class => Factory\MailerFactory::class,
],
'aliases' => [
'mailer' => Service\Mailer::class,
],
],
];
Factory example (src/Factory/UserRepositoryFactory.php):
<?php
declare(strict_types=1);
namespace App\Factory;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Interop\Container\ContainerInterface;
use App\Model\UserRepository;
class UserRepositoryFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): UserRepository
{
$db = $container->get('db'); // assumed shared DB service
return new UserRepository($db);
}
}
Trust and Data Boundaries
Configuration arrays and environment‑derived credentials enter the container only through factory code. Service classes receive their dependencies via constructor injection and never read $_ENV, getenv(), or global configuration directly. This makes the trust boundary explicit: the factory is the sole place where privileged data is accessed.
Operational Checks
Perform a compile‑style validation in a staging environment:
- Build the container from the production configuration array.
- Iterate over all service names returned by
$container->getServiceNames()and call$container->get($name)for each. - Assert that each resolved object is of the expected type or implements the required interface.
- Log any exceptions; treat them as deployment blockers.
Example validation script (scripts/validate-container.php):
<?php
require __DIR__ . '/../vendor/autoload.php';
$config = require __DIR__ . '/../config/container.php';
$container = new \Laminas\ServiceManager\ServiceManager($config['services'] ?? []);
foreach ($container->getServiceNames() as $id) {
try {
$object = $container->get($id);
echo "✓ $id\n";
} catch (\Throwable $e) {
echo "✗ $id: " . $e->getMessage() . "\n";
exit(1);
}
}
Run with:
php scripts/validate-container.php
Ensure the script is executed by a user with read access to the configuration files and execute permission on the script.
Failure Modes
- Circular dependencies – manifest as deep recursion or a
Laminas\ServiceManager\Exception\CircularDependencyExceptionat resolution time. Detect them with the validation script or by enablingdisplay_errorsanderror_reporting(E_ALL)during bootstrap. - Fallback to invokables – if a service is not found and the class is instantiable, ServiceManager may treat it as an invokable, silently hiding missing factory configuration. Prevent this by disabling the invokable fallback (
'invokables' => []) or by asserting in the validation step that every resolved service has a corresponding factory entry. - Container-as-service‑locator – injecting the whole container into controllers or domain objects obscures dependencies and hinders testing. Restrict constructor arguments to the actual services needed; use factory interfaces to enforce this rule.
When to Change the Design
Consider revising the container usage if:
- The application must interoperate with third‑party libraries that expect a PSR‑11
ContainerInterfaceand you need to expose the container at module boundaries while hiding Laminas‑specific features (delegator factories, plugin managers). In that case, program againstPsr\Container\ContainerInterfaceonly and avoid Laminas extensions. - A genuine cross‑cutting concern (e.g., attaching a logger to every service) appears; then an initializer or delegator factory becomes justified.
- The application evolves into a micro‑kernel where multiple isolated containers are required (e.g., per‑tenant containers). Then the single‑container assumption no longer holds.
In all cases, re‑run the validation script and review the trust‑boundary diagram to confirm that no hidden global state has been introduced.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.