Stopping the Service Locator Leak: Proper Dependency Injection with Laminas ServiceManager
Learn how to eliminate the Service Locator anti-pattern in Laminas by implementing proper Factory-based dependency injection for cleaner, testable code.
24 Dec 2025, 01:16 UTC

The Hidden Dependency Trap
A common pattern in Laminas applications is passing the ServiceManager (the Dependency Injection Container) directly into a class so the class can pull its own dependencies. While this feels convenient, it transforms your service into a Service Locator. This obscures what the class actually needs to function, makes unit testing difficult because you must mock the entire container, and tightly couples your business logic to the framework.
The goal is to move from pulling dependencies from a container to pushing them through the constructor. The ServiceManager should be the only part of your app that knows about the container; your services should remain agnostic.
Decoupling with Factory Classes
In Laminas, a Factory is a class that implements Laminas\ServiceManager\Factory\FactoryInterface. Its sole responsibility is to instantiate a service and inject its required dependencies. By moving the instantiation logic here, the service itself only defines what it needs in its __construct method.
This approach ensures that if a service requires a database adapter and a logger, those are passed as concrete objects. If the requirements change, you update the factory, not every single method inside the service.
Implementation: From Service Locator to Constructor Injection
Consider a UserReportService that needs a UserRepository and a MailService. Instead of passing the container, we define a dedicated factory.
The Service Class
namespace App\Service;
class UserReportService {
private $repository;
private $mailer;
// Dependencies are explicitly declared
public function __construct($repository, $mailer) {
$this->repository = $repository;
$this->mailer = $mailer;
}
public function sendWeeklyReport($userId) {
$data = $this->repository->findUserReport($userId);
return $this->mailer->send($data);
}
}
The Factory Class
Run this logic within the __invoke method. The container argument provides access to other registered services.
namespace App\Service;
use Interop\Container\ContainerInterface;
use Laminas\ServiceManager\Factory\FactoryInterface;
class UserReportServiceFactory implements FactoryInterface {
public function __invoke(ContainerInterface $container, $requestedName, array $options = null) {
// Resolve dependencies from the container here
$repository = $container->get(\App\Repository\UserRepository::class);
$mailer = $container->get(\App\Service\MailService::class);
return new UserReportService($repository, $mailer);
}
}
The Configuration
Register the factory in your module.config.php or service configuration array. This tells the ServiceManager to use the factory whenever UserReportService is requested.
'service_manager' => [
'factories' => [
\App\Service\UserReportService::class => \App\Service\UserReportServiceFactory::class,
],
],
Advanced Orchestration: Delegators and Lazy Services
Sometimes a service is too "heavy" to instantiate on every request, or you need to wrap a service in a decorator without changing the original factory. Laminas provides two powerful tools for this:
- Delegators: These allow you to intercept the creation of a service. A delegator can wrap the service in a proxy or a logging decorator before returning it to the requester.
- Lazy Services: By using a delegator to create a Virtual Proxy, the
ServiceManagerreturns a lightweight object. The actual heavy service is only instantiated the moment a method is called on that proxy.
Trade-offs and Limitations
While this pattern improves testability, it increases the number of classes in your project (one factory for every service). For very simple services with no dependencies, you can use the InvokableFactory to avoid writing a custom class.
Warning: Be cautious of circular dependencies. If ServiceA requires ServiceB, and ServiceB requires ServiceA, the ServiceManager will trigger a fatal error or stack overflow during resolution. In such cases, you must rethink your architecture or use a Lazy Service to break the cycle.
Verification and Results
To verify that your service is properly decoupled, attempt to instantiate the service in a PHPUnit test without the ServiceManager:
// In a test case
$mockRepo = $this->createMock(UserRepository::class);
$mockMail = $this->createMock(MailService::class);
// If this works, your service is decoupled from the framework
$service = new UserReportService($mockRepo, $mockMail);
If you find yourself needing to mock the ContainerInterface to test a single business method, you have a Service Locator leak that needs to be moved into a factory.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.