More on Dependency Injection, How and Why We Use It Symfony and Drupal

Posted by admin
Tuesday April 14th , 2026 10:44 p.m.



Constructor parameters with interface type hints are exactly how you declare dependencies — that’s the core of the pattern. 

Let me break this down cleanly.


What DI is actually solving
Without DI, a class reaches out and grabs what it needs:

class MyService {
 public function __construct() {
   $this->mailer = new SmtpMailer(); // hard-coded, untestable
 }
}


The class knows too much — it knows which concrete implementation to use and how to build it. You can’t swap it, mock it, or test in isolation.
With DI, the class just declares what shape of thing it needs:

class MyService {
 public function __construct(private MailerInterface $mailer) {}
}


Now it doesn’t care what mailer it gets, only that it satisfies the contract. 

 

Something else is responsible for wiring it up. That “something else” is the container.

 

The container’s job
The service container is a registry + factory. It holds a map of:
    •    service ID → how to build it (class, constructor args, calls, etc.)


When you ask the container for my.service, it resolves the full dependency graph automatically — it sees that MyService needs a MailerInterface, looks up what’s registered for that interface alias, builds that first, then injects it.


In Symfony this is configured via services.yaml or autowiring. In Drupal it’s *.services.yml.

Interface type hints = dependency declaration
Yes, explicitly. 

The type hint does two things:
    1.    Communicates intent — “I need something that can send mail,” not “I need SmtpMailer”
    2.    Gives the container a targeting mechanism — it uses the type hint to resolve which registered service to inject (via autowiring in Symfony, or explicit argument binding in Drupal)
 

In Symfony with autowiring on, you mostly just write the type-hinted constructor and it wires itself. In Drupal, you still need the *.services.yml entry with explicit arguments: ['@mailer.service'], though that’s changing with more autowiring support.

The payoff
    •    Testability — inject a mock that implements the interface, no real SMTP needed
    •    Replaceability — swap SmtpMailer for SendgridMailer in one config line, no class changes
    •    Decoupling — MyService has zero knowledge of the infrastructure layer


The interface type hint is the contract. The service container enforces and fulfills it. DI is really just the discipline of never letting a class build its own dependencies — declare them, receive them, use them.​​​​​​​​​​​​​​​​

 

 

Using the mailer example suppose to you want  a Smtpmailer object by default always - 

Questions :

How does it look at the call site? 

Do you need to instantiate the mailer each time i call myservice and pass in the argument?

$svc = new MyService(new SmtpMailer()) 

 

?

 

NO — that’s the whole point of the container. You never instantiate it yourself.


You ask the container for MyService and it handles the whole graph:

// Symfony controller (container-aware)
$myService = $this->get(MyService::class);

// or via autowired injection into the controller itself
public function __construct(private MyService $myService) {}


You never write new MyService(new SmtpMailer()) anywhere in application code. That’s the container’s job.

How the default wiring works
In Symfony services.yaml:

services:
 App\Mailer\SmtpMailer: ~         # registers it as a service
 App\Mailer\MailerInterface: '@App\Mailer\SmtpMailer'  # alias


Now anywhere something type-hints MailerInterface, Symfony autowiring resolves it to SmtpMailer automatically. One declaration, applies everywhere.
In Drupal mymodule.services.yml:

services:
 mymodule.mailer:
   class: Drupal\mymodule\Mailer\SmtpMailer

 mymodule.my_service:
   class: Drupal\mymodule\MyService
   arguments: ['@mymodule.mailer']


The mental shift
The call site doesn’t create anything. It just receives MyService already fully assembled. If you later swap SmtpMailer for SendgridMailer, you change one line in the services config — every call site gets the new implementation without touching a single class file.
new is essentially banned from service classes. If you find yourself writing it for a dependency, that’s a signal something should be a service instead.​​​​​​​​​​​​​​​​

 

 

 

How Static Methods Fit In

Static methods are the enemy of DI, for a specific reason.

class MyService {
 public function doSomething(): void {
   $result = SmtpMailer::send($message); // static call
 }
}


This is effectively the same problem as new SmtpMailer() inside the constructor. The class has a hardcoded, invisible dependency — it’s just not in the constructor where you can see and replace it. The container has no visibility into it at all.

Why it breaks the pattern
With a constructor type hint, the dependency is:
    •    declared — visible in the signature
    •    injectable — the container can fulfill it
    •    mockable — tests can swap it
With a static call, it’s:
    •    hidden — nothing in the class signature reveals it
    •    hardwired — the container can’t intercept it
    •    untestable — you can’t inject a mock; the call resolves at compile time to a specific class

The one legitimate use
Static methods are fine for stateless utilities that have no external dependencies themselves — pure functions, essentially:

// fine — no I/O, no state, no external deps
$slug = StringHelper::slugify($title);
$hex  = ColorUtil::rgbToHex(255, 0, 0);


The rule of thumb: if the static method touches I/O, state, config, or anything you’d ever want to swap or mock — it should be a service. If it’s purely transformational (in → out, no side effects) — static is acceptable.

This is directly relevant to your coding standards doc — static calls inside service methods are one of the harder anti-patterns to catch in review because they don’t show up in the constructor at all.​​​​​​​​​​​​​​​​

 

 

 

 

So now that we've created a service, you may be wondering:

 

At the call site I'm not doing:

$svc= new MyService(new SMTPMailer );

$svc->doSomething();

and am not doing this either:

$svc = MyService::doSomething();

 

Can I do: 

 

$svc = \Drupal::service('my_module.mailer')->doSomething();

In a hook, where you have no access to $this, yes.

 

In a controller: no -  that just trades one static call for another. The whole point is to inject it.

In a controller, form, block, or any service class:

final class MyController extends ControllerBase {
  public function __construct(
    private readonly Connection $externalDb,
  ) {}

  public static function create(ContainerInterface $container): self {
    return new self(
      $container->get('mymodule.external_db'),
    );
  }

  public function build() {
    $result = $this->externalDb->select('some_table', 's')->execute();
    // ...
  }
}

For a custom service of your own, even cleaner with autowiring:

services:
  mymodule.my_service:
    class: Drupal\mymodule\MyService
    autowire: true
    arguments:
      $externalDb: '@mymodule.external_db'

The legitimate exceptions where \Drupal::service() is acceptable:

  • Inside .module / .theme procedural hooks where you have no $this and no create()hook_cron, hook_preprocess_HOOK, etc. Even then, the modern pattern is to keep the hook body thin and delegate to a service via an OOP hook class (Drupal 11.1+) so you can inject properly.
  • hook_update_N and install hooks, where the container may be in a weird state.
  • Inside a .install file's requirements check.

Everywhere else — controllers, forms, blocks, plugins, services, event subscribers, commands — constructor injection. The static service locator is a fallback for places the DI container can't reach you, not a convenience.

This is exactly the kind of thing worth codifying in your standards doc as a hard rule with the narrow exceptions enumerated, so PR rejections cite the doc instead of taste.