Using the Container Well

Constructor injection by default, no container inside your classes, and a dependency list short enough to hold in your head.

Syntax#[AutowireLocator(['card' => CardPayment::class])] private ContainerInterface $handlers

The 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

Example · php
// 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:4599

When 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.

Example · php
// 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.

Example · php
// 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.csv

Splitting 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.

Example · php
// 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

  • Be the first to comment on this lesson.