Using the Container Well
Constructor injection by default, no container inside your classes, and a dependency list short enough to hold in your head.
#[AutowireLocator(['card' => CardPayment::class])] private ContainerInterface $handlersThe container can give a class anything. That is precisely why a class should ask for as little as possible.
Constructor injection is the default
List dependencies as constructor arguments, typed on an interface where one exists, stored in promoted private properties. The signature then states the class's requirements. Anyone reading it sees the full list, and any test can build the class with new and pass fakes.
Why injecting the container is a step backwards
Injecting the container, or anything you call get() on with an arbitrary id, replaces that list with a lookup. Three things get worse. The dependencies become invisible, so what a class costs can no longer be read from its signature. A wrong or removed id fails when the method runs rather than when the container compiles, so lint:container cannot catch it. And because everything is reachable, the class drifts into doing several jobs.
When a locator is right
One case survives: choosing between several interchangeable handlers at runtime, where building them all would be waste. For that, inject a service locator — a small read-only container holding only the services you listed. #[AutowireLocator([...])] on a Psr\Container\ContainerInterface argument is the shorthand; implementing ServiceSubscriberInterface with a getSubscribedServices() method is the longhand, and the two produce the same thing. The map stays in your own file either way.
Keeping dependencies countable
Six or seven constructor arguments usually means two classes sharing a file. Split by the job the code does, not by the layer it sits in.
Example
// src/Payment/PaymentProcessor.php
declare(strict_types=1);
namespace App\Payment;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
final class PaymentProcessor
{
public function __construct(
// a locator over exactly these two services, and nothing else
#[AutowireLocator([
'card' => CardPayment::class,
'bank_transfer' => BankTransferPayment::class,
])]
private ContainerInterface $handlers,
) {
}
public function pay(string $method, int $amountInMinorUnits): string
{
// check first: get() on an unknown key throws
if (!$this->handlers->has($method)) {
throw new \InvalidArgumentException(sprintf('Unknown payment method "%s".', $method));
}
// only the handler asked for is built
return $this->handlers->get($method)->pay($amountInMinorUnits);
}
}
// With CardPayment::pay() returning 'card:'.$amount:
// $processor->pay('card', 4599)
// Output: card:4599When to use it
- Pick a payment, notification or export handler from a request value without building all of them.
- Make a service testable by instantiating it with new and passing stub implementations.
- Decide what to do with a class whose constructor has grown past half a dozen arguments.
More examples
The same locator written longhand
This is the attribute version desugared: the same map, moved from the argument to a static method. Both give the class a locator limited to a list you wrote, which is the difference between a locator and the whole container. Prefer the attribute unless the list has to be assembled by code.
// src/Payment/PaymentProcessor.php
declare(strict_types=1);
namespace App\Payment;
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
final class PaymentProcessor implements ServiceSubscriberInterface
{
public function __construct(
private ContainerInterface $handlers,
) {
}
// the container reads this and injects a locator holding just these services
public static function getSubscribedServices(): array
{
return [
'card' => CardPayment::class,
'bank_transfer' => BankTransferPayment::class,
];
}
public function pay(string $method, int $amountInMinorUnits): string
{
if (!$this->handlers->has($method)) {
throw new \InvalidArgumentException(sprintf('Unknown payment method "%s".', $method));
}
return $this->handlers->get($method)->pay($amountInMinorUnits);
}
}Dependencies you can read off the signature
Two arguments, two interfaces, one job. Nothing here knows the container exists, so the class can be built with new in a test while the production wiring stays in configuration. This is the shape to aim for by default.
// src/Export/ExportRunner.php
declare(strict_types=1);
namespace App\Export;
final class ExportRunner
{
public function __construct(
// typed on interfaces, so a test can pass a stub of each
private ExporterInterface $exporter,
private ExportStorageInterface $storage,
) {
}
/**
* @param list<array<string, string|int>> $rows
*
* @return string the stored file name
*/
public function run(string $name, array $rows): string
{
$contents = $this->exporter->export($rows);
$filename = sprintf('%s.%s', $name, $this->exporter->format());
$this->storage->write($filename, $contents);
return $filename;
}
}
// With the CSV exporter injected:
// $runner->run('orders', [['id' => 1]])
// Output: orders.csvSplitting a constructor that grew
A long constructor is a measurement, not a style problem: it counts the things the class touches. Splitting by job gives each new service a short argument list and a name, and the coordinating class keeps three dependencies instead of seven. Injecting the container would have hidden those seven rather than reduced them.
// src/Order/OrderConfirmation.php
declare(strict_types=1);
namespace App\Order;
use App\Export\ExportRunner;
// before: this class took seven arguments and did three jobs -
// formatting, exporting and notifying. Each job became its own
// service, and this class now coordinates them.
final class OrderConfirmation
{
public function __construct(
private OrderSummaryFormatter $formatter,
private ExportRunner $exports,
private OrderNotifierInterface $notifier,
) {
}
/**
* @param list<array<string, string|int>> $lines
*/
public function confirm(string $reference, array $lines): string
{
$summary = $this->formatter->format($reference, $lines);
$filename = $this->exports->run($reference, $lines);
$this->notifier->notify($reference, $summary, $filename);
return $filename;
}
}
// $confirmation->confirm('ORD-14', [['id' => 1]])
// Output: ORD-14.csv
Discussion