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 staticConfig::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
<?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')}."); } }
<?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());