Autowiring, and what the container is allowed to guess

The services file in that project was 418 lines and every new class added four more. Nobody enjoyed writing them, everybody copied the previous entry, and about a third of the definitions were wrong in ways that did not matter because nothing resolved them. Symfony 4 replaces the whole file with twelve lines, and the interesting question is what it can and cannot work out on its own.

The symptom

# services.yml — four lines per class, forever
services:
    app.repository.orders:
        class: AppBundleRepositoryOrderRepository
        arguments: ['@doctrine.orm.entity_manager', '@logger']

    app.gateway.stripe:
        class: AppBundleGatewayStripeGateway
        arguments: ['@app.http_client', '%stripe_key%']
        public: true

Adding a constructor argument meant editing the class and the YAML, and forgetting the second produced a runtime error about an argument count. The public: true on the last one had been added by somebody debugging in 2016 and never removed, which meant the container held a reference to it forever.

Why it happens

A container cannot construct what it cannot describe, and for a long time the only description available was an explicit list of arguments. Type hints existed but the container had no reason to read them, because a type hint names an interface and an interface does not name an implementation.

Autowiring works by inverting the default: every class is registered under its own fully qualified name, so a type hint for OrderRepository resolves to the service called OrderRepository. That is a convention rather than an inference, and understanding it as a convention explains every case where it stops working.

The fix

The twelve lines

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    App:
        resource: '../src/*'
        exclude: '../src/{DependencyInjection,Entity,Migrations,Tests}'

    AppController:
        resource: '../src/Controller'
        tags: ['controller.service_arguments']

autoconfigure is the half that gets less attention and does as much work: it applies the right tag based on the interfaces a class implements, so an event subscriber is registered as one by implementing EventSubscriberInterface and nothing else. public: false means services are only reachable by injection, which lets the compiler inline and remove the ones nothing uses.

The exclusions are the classes that are data rather than services. Entities have scalar constructors and are created by Doctrine; migrations are executed by the migration tool. Forgetting to exclude a directory produces a container that tries to autowire something with a string $name constructor and fails at compile time — which is the right time, and is confusing the first time it happens.

Where the guessing stops

Three cases, and all three are the container correctly refusing to guess rather than a limitation.

# 1. a scalar. no type can identify which string you meant.
services:
    AppGatewayStripeGateway:
        arguments:
            $apiKey: '%env(STRIPE_KEY)%'
            $timeout: 3

# 2. two implementations of one interface.
    AppMailerSmtpMailer: ~
    AppMailerLogMailer: ~
    AppMailerMailerInterface: '@AppMailerSmtpMailer'   # the default

    AppCommandPreviewCommand:
        arguments:
            $mailer: '@AppMailerLogMailer'                # the exception

# 3. named binding, when the same scalar is wanted in many places
    _defaults:
        bind:
            $projectDir: '%kernel.project_dir%'
            $stripeKey: '%env(STRIPE_KEY)%'

Binding by argument name rather than by type is what makes the scalar cases tolerable — declare $projectDir once in _defaults and every constructor taking that parameter name receives it. It is a convention with a real failure mode: renaming the parameter silently unbinds it, and the error appears at compile time rather than at the rename.

The two-implementations case has a second solution worth knowing. An interface can be given a default implementation with an alias, as above, or the choice can be made per environment by putting the alias in config/services_dev.yaml — which is how a test double gets injected everywhere without any test-specific code in the application.

Tagged services, without a compiler pass

Collecting every implementation of an interface into a registry used to require a compiler pass. Autoconfigure plus a tagged iterator covers it.

// src/Kernel.php
protected function build(ContainerBuilder $container): void
{
    $container->registerForAutoconfiguration(ExporterInterface::class)
        ->addTag('app.exporter');
}

final class ExportRegistry
{
    public function __construct(iterable $exporters) { /* ... */ }
}

// config/services.yaml
//   AppExportExportRegistry:
//       arguments: [!tagged_iterator app.exporter]

A tagged iterator is lazy — the services are constructed as the iteration reaches them rather than all at once — which matters when the registry has thirty entries and a request uses one. Ordering is by tag priority and is worth setting explicitly if it matters, because the default is registration order and registration order is not something anyone should depend on.

Compiling the container, and errors at build time

The container is built once and frozen into generated PHP, so everything above is resolved during a cache warm rather than during a request. That is what makes it fast, and it changes where failures appear.

$ bin/console cache:clear --env=prod --no-debug

  Cannot autowire service "AppServiceCheckout": argument "$gateway"
  of method "__construct()" references interface
  "AppGatewayGatewayInterface" but no such service exists.
  You should maybe alias this interface to one of these existing
  services: "AppGatewayStripeGateway".

$ bin/console lint:container
[OK] The container was linted successfully: all services are injectable.

$ bin/console debug:autowiring GatewayInterface

lint:container in CI is what makes this a build failure rather than a deploy failure. Without it, a type hint that cannot be satisfied compiles fine in the development environment — where the container is rebuilt lazily and the broken service may never be resolved — and fails on the first production request that touches it.

debug:autowiring answers the question people actually ask, which is what they are allowed to type-hint for. It is faster than searching the vendor directory and it reads the compiled container, so it reflects what will run rather than what the configuration appears to say.

Verifying it worked

$ git diff --stat config/services.yaml
 config/services.yaml | 418 +----------------------------------

$ bin/console debug:container | tail -1
// 412 services
$ bin/console debug:container --show-private | tail -1
// 412 services  (391 private)

$ bin/console cache:clear --env=prod && bin/console lint:container
[OK]

$ vendor/bin/phpunit
OK (318 tests, 941 assertions)

The private count is the number worth looking at. Three hundred and ninety-one services the compiler is free to inline or remove is a container that can optimise itself, and the twenty-one public ones are a list short enough to review — every one of them is something reaching into the container by name, which is usually legacy code and occasionally a genuine requirement.

What this costs

The dependency graph is no longer readable in one file, and that is a real loss. Answering “what does this service receive” now means reading the constructor, and answering “what receives this service” means debug:container --reverse or a grep. On the whole that is a better arrangement — the constructor is the truth and the YAML was a duplicate of it — but a newcomer who could once read the wiring in one sitting no longer can.

The other cost is that the failure mode moved. An explicit definition failed loudly when its dependency was missing; autowiring fails when a type hint is ambiguous, which is a different and slightly more abstract error, and it fails at compile time in a message that names the class rather than the line. Getting lint:container into CI on the first day is what keeps that from being discovered in production, and it is the one step of this migration that is easy to skip.