levelmax World 5 Explicit Design
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 5.4 ~14 min Target: PHPStan max

Dependency injection without magic

To test the order confirmation email, you have to send an order confirmation email. Somewhere in send(), the class asks a global container for a mailer, and the only mailer the container has is the real one. It also reads the real time, so the delivery date in the email changes depending on which day the tests run.

None of that is visible from outside. new OrderConfirmation() looks like it needs nothing at all.

Hidden dependencies

A class has a dependency whenever it needs something else to do its job. It's hidden when the class goes and gets it itself:

  • Building it: new SmtpMailer('smtp.example.com') inside a method.
  • Fetching it from somewhere global: Container::get('mailer'), a static Config::get(), a singleton's ::instance().
  • Reading the world: new DateTimeImmutable(), random_int(), $_SERVER.

Each one means the class can only ever run with the real thing, so tests can't swap it for a fake (4.3) or a stopped clock (4.4). And the signature doesn't tell you what the class really needs, so neither do you.

A service locator costs you PHPStan too. Container::get() can return anything, so it returns mixed, and at max every call on its result is an error you can only silence with an @var claim, which is exactly what 2.6 said not to do.

Dependency injection

The fix is a fancy name for a simple thing: ask for dependencies as constructor parameters, typed as interfaces.

final readonly class OrderConfirmation
{
    public function __construct(
        private Mailer $mailer,
        private Clock $clock,
    ) {}
}

The constructor is now an honest list of what the class needs. Tests pass in a fake mailer and a stopped clock. PHPStan knows exactly what $this->mailer is. And if the list grows to eight parameters, that's not a DI problem, it's the class telling you it does too much.

The composition root

Something still has to choose the real SmtpMailer. Do it in one place, near where the application starts, with plain new:

new OrderConfirmation(new SmtpMailer('smtp.example.com'), new SystemClock());

That's all DI is. A container that autowires constructors (Laravel's, Symfony's, PHP-DI) is this same line written for you. It's a convenience, not a requirement, and it's fine as long as your classes never mention it.

new is still fine inside classes for values: new Money(...), new DateTimeImmutable('2026-03-02'), an exception. Inject services, the things with behaviour you'd want to swap. Build values where you need them.

Power-up

Before
<?php

final class OrderConfirmation
{
    public function send(Order $order): void
    {
        $mailer = Container::get('mailer');                         // mixed, and always the real one
        $arrivesBy = (new DateTimeImmutable())->modify('+3 weekdays'); // whatever today is

        $mailer->send($order->email, "Order {$order->reference} confirmed", "…by {$arrivesBy->format('l j F')}.");
    }
}
After
<?php

final readonly class OrderConfirmation
{
    public function __construct(
        private Mailer $mailer,
        private Clock $clock,
    ) {}

    public function send(Order $order): void
    {
        $arrivesBy = $this->clock->now()->modify('+3 weekdays');

        $this->mailer->send($order->email, "Order {$order->reference} confirmed", "…by {$arrivesBy->format('l j F')}.");
    }
}

// The composition root, once: new OrderConfirmation(new SmtpMailer('smtp.example.com'), new SystemClock());

Quick check

1. Which of these is a hidden dependency?
2. Why does a service locator make PHPStan at max unhappy?
3. Do you need a DI container to do dependency injection?

CHALLENGE

OrderConfirmation can't be tested without sending a real email: it fetches its mailer from a global Container and reads the real time. Its signature doesn't admit to either, and PHPStan can't check a mailer that arrives as mixed.

Give it a constructor that takes a Mailer and a Clock, and use those instead. ShopServices (read only) already builds it that way, with the real ones. The email itself shouldn't change.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.