Dependency inversion in Symfony: what autowiring made easy, and what it made optional

Autowiring is one of the better things to happen to PHP. Typehint a class in a constructor, and Symfony finds it, builds it, and hands it over. No configuration, no factory, no container gymnastics. A generation of PHP developers now writes dependency injection without ever thinking about it, which is exactly what good tooling should achieve.

It also quietly made dependency inversion optional, and most codebases have taken the option.

The two things that get conflated

Dependency injection is a technique: a class receives its collaborators instead of constructing them. It is about how an object gets what it needs.

Dependency inversion is a design principle — the D in SOLID: high-level policy should not depend on low-level detail; both should depend on an abstraction. It is about which direction the dependency arrow points.

You can do the first perfectly and none of the second:

final class OrderConfirmationService
{
    public function __construct(
        private MailchimpClient $mailer,
        private StripeGateway $payments,
    ) {}
}

Textbook injection. Nothing is constructed inside, everything arrives through the constructor, and it will autowire with zero configuration. It is also welded to two vendors. Your business rules about confirming an order now depend on Mailchimp's client class — the high-level policy depends on the low-level detail, which is the precise thing the principle prohibits.

Inverted, the arrow turns around:

final class OrderConfirmationService
{
    public function __construct(
        private NotificationSender $notifications,
        private PaymentProcessor $payments,
    ) {}
}

Same injection mechanism. Different architecture. MailchimpNotificationSender now depends on your interface, rather than your service depending on Mailchimp.

Who owns the interface

This is the part most explanations skip, and without it the principle degenerates into "add interfaces."

The interface belongs to the consumer, not the implementation. NotificationSender is defined by, and lives alongside, the code that needs notifications sent. It is expressed in that code's vocabulary — the domain's language, not the vendor's.

The tell that you got it wrong is an interface that mirrors a library's API. If NotificationSender has subscribeToList() and setMergeFields(), you have not inverted anything. You have written a Mailchimp-shaped interface and put your own namespace on it, and the day you swap providers you will discover the abstraction leaked the entire time.

A properly owned interface is usually smaller and blunter than the library it wraps:

interface NotificationSender
{
    public function send(Notification $notification): void;
}

One method, in your terms. The adapter absorbs the vendor's complexity, and if that adapter is ugly, that is the correct place for the ugliness to live.

How autowiring nudges you the wrong way

Here is the mechanical reason coupled code is so common in modern Symfony: autowiring resolves by type, and the zero-configuration path is the concrete one.

Typehint MailchimpClient and it works immediately — the container knows that class, registers it by its FQCN, and injects it. Typehint NotificationSender and the container has no idea which implementation you meant, so you get an exception until you write:

services:
  App\Notification\NotificationSender: '@App\Notification\MailchimpNotificationSender'

That is not a large cost, but it is a non-zero one paid at the exact moment you are trying to make progress, in exchange for a benefit that arrives months later. Tooling that makes the coupled thing free and the decoupled thing cost a config edit will produce a lot of coupled code. That is not a criticism of anyone's discipline; it is what defaults do.

Modern Symfony has closed most of the gap. #[AsAlias] lets the implementation declare itself:

#[AsAlias(NotificationSender::class)]
final class MailchimpNotificationSender implements NotificationSender
{
    // ...
}

And where an interface has exactly one implementation in the container, Symfony will alias it automatically. Worth knowing that this shortcut evaporates the moment you add a second implementation — which is the day you most need things to keep working — so an explicit alias or #[AsAlias] is the more stable choice.

For genuine multi-implementation cases, tagged iterators are the idiomatic tool:

final class NotificationDispatcher
{
    public function __construct(
        #[AutowireIterator('app.notification_sender')]
        private iterable $senders,
    ) {}
}

Every service tagged app.notification_sender arrives as an iterable, and adding a channel means adding a class. The dispatcher never changes — which is the open/closed principle falling out of dependency inversion, as it tends to.

When not to bother

An article that only argued for more interfaces would be advocating a real and common failure mode, so:

One implementation, no prospect of a second. An interface with a single implementer, both written by you, both changed together, is a file you now maintain twice. "Testability" is the usual justification and it is usually wrong — modern PHPUnit mocks concrete classes fine, and if a class is hard to test the interface rarely fixes the actual reason.

Entities and value objects. These are data. Inverting them adds indirection to something that has no behaviour to swap.

Framework abstractions you already trust. Symfony's own LoggerInterface, CacheInterface, EventDispatcherInterface are stable, well-designed, and standards-backed. Wrapping them in your own interfaces is inversion theatre.

A more useful trigger than "is this a dependency" is: does this cross a boundary I do not control? Third-party APIs, payment providers, mail services, storage backends, anything with a vendor's release schedule attached. Those are the arrows worth turning around. Your own domain services, mostly, are not.

Drupal 11: the same container, different defaults

Drupal uses Symfony's dependency injection component, and on Drupal 11 the gap in developer experience has largely closed — but the culture around it has not, which produces an interesting split.

Autowiring is on for core, opt-in for you

Core enables it globally in core.services.yml:

services:
  _defaults:
    autowire: true

and most explicit arguments: lists in core were deleted as a result. Your module does not inherit that — _defaults is per-file — so you enable it in your own *.services.yml:

services:
  _defaults:
    autowire: true

  Drupal\my_module\OrderConfirmationService: ~

Note the shape of that declaration: the FQCN serves as both service ID and class, and the definition body is empty. That is a long way from the argument lists Drupal was known for.

The nudge points the other way here

This is the part worth pausing on, because it reverses the Symfony problem described above.

Drupal's service IDs are strings — entity_type.manager, module_handler — which on its own would make autowiring impossible, since autowiring resolves by type. Core solved this by adding interface aliases:

Drupal\Core\Extension\ModuleHandlerInterface: '@module_handler'
Drupal\Core\StringTranslation\TranslationInterface: '@string_translation'

All standalone core classes and every non-ambiguous core interface — meaning one with a single implementation — are autowirable this way.

The consequence is that in Drupal 11, the zero-configuration path is the interface typehint. Write ModuleHandlerInterface $moduleHandler and it resolves. There is no concrete class you could typehint instead that would be easier. Where Symfony's defaults quietly reward coupling to vendor classes, Drupal's reward depending on abstractions — because the aliases were built on interfaces out of necessity.

Two limits. Ambiguous interfaces — CacheBackendInterface, where a dozen implementations exist — cannot be resolved automatically, so you name the one you want and let the rest infer:

services:
  _defaults:
    autowire: true

  Drupal\my_module\OrderConfirmationService:
    arguments:
      $cache: '@cache.default'

And the official documentation's own position, as of the Drupal 10/11 guidance, is to treat autowiring as an optional convenience rather than the default pattern, preferring explicit definitions where reliability and clarity matter. That is more conservative than Symfony's culture, and it is defensible: the container is compiled and dumped, so an autowiring surprise surfaces at cache rebuild rather than at the line that caused it.

Controllers and AutowireTrait

Since 10.2, controllers autowire and the create() boilerplate is no longer always needed. For classes built by a factory — controllers, plugins, forms — AutowireTrait supplies the create() method for you:

use Drupal\Core\DependencyInjection\AutowireTrait;

final class OrderController extends ControllerBase
{
    use AutowireTrait;

    public function __construct(
        private readonly NotificationSenderInterface $sender,
    ) {}
}

That is roughly twenty lines of factory boilerplate replaced by one trait, and the constructor still declares its dependencies honestly.

The real problem, which autowiring does not fix

None of the above touches the pattern that actually dominates contrib:

public function doSomething() {
  $sender = \Drupal::service('my_module.notification_sender');
  $sender->send($notification);
}

That is the service locator pattern. The class reaches into a global registry and pulls out what it wants. The dependency is real but invisible — nothing in the constructor, nothing in the signature, nothing a reader or a static analyser can see.

Three costs, in order of how much they hurt:

It is not testable in isolation. A unit test must bootstrap or fake a global container before the class will run at all. This single pattern is responsible for an enormous amount of Drupal code that has integration tests or no tests, because a unit test would cost more than the code.

The dependencies are undiscoverable. Constructor injection makes a class's needs a matter of public record. A static call hides them in the middle of a method body, and the only way to enumerate them is to read every line.

It couples to a string. 'my_module.notification_sender' is not a type. Nothing checks it until runtime, no IDE follows it, no rename tool updates it.

Core is explicit that this is a fallback for procedural code — hooks, .module files, places where no object exists to inject into. There it is legitimate, because there is no alternative. In a class it almost never is.

The correction, where AutowireTrait does not apply

Plugins with the older constructor signature still need an explicit factory:

final class MyBlock extends BlockBase implements ContainerFactoryPluginInterface
{
    public function __construct(
        array $configuration,
        $plugin_id,
        $plugin_definition,
        private readonly NotificationSenderInterface $sender,
    ) {
        parent::__construct($configuration, $plugin_id, $plugin_definition);
    }

    public static function create(
        ContainerInterface $container,
        array $configuration,
        $plugin_id,
        $plugin_definition,
    ): static {
        return new static(
            $configuration,
            $plugin_id,
            $plugin_definition,
            $container->get('my_module.notification_sender'),
        );
    }
}

The static container call still exists — but it lives in exactly one place, a factory method that exists for the purpose, and the constructor declares honestly what the class needs. Everything downstream is testable with a plain new.

Typehint the interface rather than the concrete class. Drupal service classes are routinely swapped by contrib through service decoration and container alteration, so depending on core interfaces is not hypothetical purity — it is how a substantial part of the ecosystem works, and it is the same property that makes autowiring resolve in the first place.

What to take from this

Injection is a mechanism and inversion is a decision, and having the mechanism handed to you for free makes the decision easy to skip. In Symfony that shows up as constructors full of vendor classes. In Drupal it shows up as \Drupal::service() in the middle of a method, which is the same problem wearing a different coat: a dependency that exists but is not declared.

The question that resolves most cases is not "should this be an interface" but whose vocabulary is this expressed in, and who controls it. If the answer is a vendor, turn the arrow around. If it is you, you probably already own it, and adding an interface is just another file to keep in sync.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.