Where autowiring reaches its limits and how a service locator enables deliberate, late access to multiple implementations
Autowiring resolves most dependencies in Symfony automatically based on type, and it is rightfully the default approach for constructor injection. But as soon as several services implement the same interface, for example multiple payment gateways or multiple export formats, the container can no longer decide unambiguously which concrete implementation is meant, and it fails at compile time. This article shows how ServiceLocator and the AutowireLocator attribute provide a deliberate, explicit, and still testable way out for exactly this case, and why that is fundamentally different from the classic injected service container, which is rightly considered an anti-pattern.
Table of Contents
- 1. Where Autowiring Reaches Its Limits
- 2. ServiceLocator and the AutowireLocator Attribute
- 3. Deliberate, Late Service Access Instead of Eagerly Injecting Every Candidate
- 4. The Difference from the Classic Container-as-Dependency Anti-Pattern
- 5. Tagged Services with Priority as an Alternative for Ordered Lists
- 6. Automatically Indexing Tagged Services into a Locator
- 7. Testability of Code That Uses a Service Locator
- 8. When Classic Autowiring Remains the Better Choice
- 9. Common Pitfalls with Service Locators in Practice
- 10. Summary
- 11. FAQ
1. Where Autowiring Reaches Its Limits
Autowiring analyzes the type hints in a service's constructor and looks in the container for exactly one service that satisfies that type. For a concrete interface with exactly one implementation this works smoothly, because Symfony automatically registers an alias from the interface to the single implementation. Things get tricky as soon as several classes implement the same interface, for example StripeGateway and PaypalGateway both implementing GatewayInterface, because the container can no longer automatically decide which of the two is meant whenever another service requests GatewayInterface in its constructor.
In that case the container aborts compilation with an error message stating, in essence, that autowiring is ambiguous because multiple services qualify for the interface. The obvious fix, setting an explicit alias for one of the two services, only works if the same implementation is always meant. As soon as a service has to choose between multiple implementations depending on runtime state, for example a payment provider chosen by the user, a static alias is no longer enough, and this is exactly where service locators come in.
2. ServiceLocator and the AutowireLocator Attribute
A ServiceLocator is a special container that only knows an explicitly defined subset of services and grants access to it through has() and get(), with the list of allowed service ids fixed at compile time. That restriction is the crucial difference from the full container: a ServiceLocator can never reach an arbitrary service in the container, only the ones explicitly declared. Since Symfony 6.4, such a locator can be declared conveniently right on a constructor parameter through the AutowireLocator attribute, without needing any additional services.yaml configuration.
The example below shows a PaymentGatewayResolver that picks the right gateway implementation based on a payment method key determined at runtime. Its constructor does not receive a fixed list of gateway objects, but a ServiceProviderInterface, a typed, lightweight variant of a service locator that comes from the Symfony Service Contracts package. It is worth noting that the gateways themselves remain lazy: only the actual get() call instantiates the concrete service, which matters especially for expensive services that build their own HTTP clients.
<?php
declare(strict_types=1);
namespace App\Payment;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Contracts\Service\ServiceProviderInterface;
final class PaymentGatewayResolver
{
/**
* @param ServiceProviderInterface<GatewayInterface> $gateways
*/
public function __construct(
#[AutowireLocator([
'stripe' => StripeGateway::class,
'paypal' => PaypalGateway::class,
])]
private readonly ServiceProviderInterface $gateways,
) {
}
public function resolve(string $method): GatewayInterface
{
if (!$this->gateways->has($method)) {
throw new \InvalidArgumentException(sprintf('Unknown payment method "%s".', $method));
}
return $this->gateways->get($method);
}
}
3. Deliberate, Late Service Access Instead of Eagerly Injecting Every Candidate
An obvious but weaker approach would be to simply inject all candidate gateways as an array in the constructor, for example through a tagged iterator. That works technically, but it fundamentally instantiates every single service immediately when the resolver is built, even if only one of them is actually needed at runtime. For gateways that already build an HTTP client or validate configuration in their constructor, that means unnecessary overhead on every single request, regardless of which payment method actually ends up being chosen.
A service locator solves exactly that problem, because internally it is itself a small container whose entries are only instantiated on actual access through get(). The resolver itself therefore stays cheap to construct, no matter how many gateways are registered overall, and only the service that is actually needed gets built at runtime. This behavior matters especially for applications with many alternative implementations, such as export formats, notification channels, or storage backends, where typically only a single implementation is actually needed per request.
4. The Difference from the Classic Container-as-Dependency Anti-Pattern
The classic anti-pattern consists of injecting the full ContainerInterface directly into a service and querying arbitrary service ids from it via get(). That pattern is rightly considered problematic, because it makes a class's actual dependencies completely invisible: you have to read the entire method body to figure out which services a class actually needs, instead of being able to read it off the constructor signature. On top of that, this pattern effectively decouples the service from the container's compile-time checking, so a typo in a service id only surfaces as an error at runtime, once the affected code path actually executes.
A service locator avoids both problems, because the set of reachable services is declared explicitly in the constructor, either through the AutowireLocator attribute or through a corresponding services.yaml configuration using the container.service_locator tag. The container checks at compile time whether every referenced service id actually exists, and a glance at the class immediately shows the limited set of services it can choose from. The decisive difference is therefore not lazy instantiation as such, but the explicit, checked restriction to a deliberately chosen subset of services instead of unrestricted access to the entire container.
5. Tagged Services with Priority as an Alternative for Ordered Lists
When what is needed is not targeted access to exactly one service but an ordered list of every implementation, for example a chain of validation rules or a series of export strategies that all get tried one after another, a tagged iterator is usually the better fit than a service locator. Through the AutowireIterator attribute or the classic services.yaml configuration with tag: app.export_strategy, all services sharing a common tag can be injected as an iterable collection, with the order controlled by the priority key on the tag.
A higher priority value means an earlier position in the iteration, which matters for a chain of middleware-like handlers or for several competing export strategies where the first applicable implementation should win. It is important not to confuse the tagged iterator with the service locator: the iterator is for 'try all of them in order', while the locator is for 'reach directly into exactly one known key'. Symfony even lets you combine both mechanisms, using the same tag for both an AutowireIterator and an AutowireLocator with an indexing key.
6. Automatically Indexing Tagged Services into a Locator
Instead of manually listing the mapping from key to service class in the AutowireLocator attribute as in the first example, that mapping can also be derived automatically from a tag, provided every gateway class labels itself with a meaningful index. Each implementation gets the AutoconfigureTag('app.payment_gateway', ['key' => 'stripe']) attribute, and the locator is then created via AutowireLocator('app.payment_gateway') without an explicit enumeration, with Symfony indexing automatically based on the key attribute. This approach scales considerably better when new gateways are added frequently, since the resolver itself no longer needs to be touched.
The downside of this automatic indexing is that the mapping from key to class is scattered across multiple files instead of being visible in one central place, which in smaller projects with few, stable implementations tends to create unnecessary indirection. As a rule of thumb, a fixed, small set of implementations that rarely changes reads better as an explicit enumeration in the AutowireLocator attribute, while a growing, frequently extended set of plugins or strategies benefits from automatic, tag-based indexing that noticeably reduces maintenance effort.
7. Testability of Code That Uses a Service Locator
In a unit test, a service locator can be built without the full Symfony container by instantiating the class Symfony\Component\DependencyInjection\ServiceLocator directly and passing it an array of closures that each return a test double. That keeps the PaymentGatewayResolver from the example above fully testable in isolation, without having to build a test container with real gateway implementations, which keeps test run times short and makes the tests independent from the actual service configuration.
This advantage over the classic container anti-pattern should not be underestimated: a service that gets the full ContainerInterface injected is hard to mock meaningfully in a unit test, because the container could potentially return any service in the system, and a mock for that would have to be either incomplete or needlessly complex. A service locator, by contrast, maps exactly onto the interface the test actually needs, namely exactly the set of services the class under test actually requests, no more and no less.
8. When Classic Autowiring Remains the Better Choice
Despite all the benefits of service locators, autowiring should remain the default, because a service locator adds an extra layer of indirection that makes the calling code harder to read, turning a simple $this->gateway->charge() into $this->gateways->get('stripe')->charge(). Where a class genuinely only ever needs exactly one fixed implementation of an interface, a plain, typed constructor parameter with autowiring remains the clearest and least surprising solution, and a single extra alias in services.yaml is enough to resolve ambiguity for that one case.
A good rule-of-thumb test is whether the choice of concrete implementation is only made at runtime based on a value the constructor itself does not yet know, such as a value chosen by the user from a request. If the implementation, on the other hand, is already uniquely determinable when the container is being assembled, for example because exactly one gateway is always active per environment, a service locator is overkill, and a simple, explicit alias or an environment-specific service swap through compiler passes is the more fitting, simpler solution.
9. Common Pitfalls with Service Locators in Practice
A common mistake is injecting the service locator under the same name as the actual gateway interface, so the code no longer makes it obvious at a glance whether a concrete implementation or a locator is in play. A descriptive property name like $gateways instead of $gateway makes that difference immediately visible and prevents a colleague from accidentally calling a method directly on the locator instead of on the concrete implementation fetched via get(). Equally important is clear error handling for the case where a requested key does not exist, since has() should be checked before every get() call, replacing an unclear Symfony-internal exception with a meaningful, domain-specific one.
Another pitfall is reaching for a service locator for a set of only two or three implementations that are unlikely to change any time soon. In that case, a simple match expression in the code that distinguishes between the few concretely injected services is often just as readable and just as testable as a full-blown locator, without the extra container configuration. Service locators pay off primarily once the number of implementations can grow without the calling code needing to be adjusted every time.
| Mechanism | When It Fits | Access Style |
|---|---|---|
| Autowiring | Exactly one implementation per interface | Direct via constructor type hint |
| Alias in services.yaml | Multiple implementations but statically unambiguous | Wired at compile time |
| ServiceLocator / AutowireLocator | Runtime-dependent choice among known keys | has()/get() with an explicit key |
| Tagged Iterator / AutowireIterator | Iterating over every implementation in order | Iteration in priority order |
| ContainerInterface injected directly | No legitimate use case (anti-pattern) | Unrestricted get() on arbitrary ids |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Service Locators vs. Autowiring: The Essentials at a Glance
Limit of autowiring
Multiple implementations of the same interface make automatic resolution ambiguous.
ServiceLocator
An explicitly restricted container for deliberate, lazy access via has()/get().
Not an anti-pattern
Unlike the full container, the reachable set of services is checked and visible.
Tagged services
For ordered lists with priority, an iterator is the right choice, not a locator.