Впровадження залежностей вкладається в одне речення: клас не добуває собі співпрацівників, а отримує їх готовими. Порівняйте два варіанти. У першому RegisterUser усередині методу пише new SmtpMailer(getenv('MAIL_DSN')) і намертво прибиває до себе конкретний транспорт, змінну оточення й момент створення підключення. У другому той самий клас оголошує в конструкторі параметр типу Mailer, і питання «який саме мейлер» вирішується на рівень вище. Найшвидше виграш видно в тестах: щоб перевірити реєстрацію користувача, не треба піднімати SMTP чи мокати статичні виклики, достатньо передати простий клас-заглушку, який складає листи в масив.
Конструктор і сеттер вирішують одну задачу з різною жорсткістю. Параметр конструктора робить залежність частиною контракту: без неї обʼєкт створити неможливо, поле можна оголосити private readonly, а список параметрів чесно показує, скільки всього класу треба для роботи. Сеттер лишає вікно, у якому обʼєкт уже існує, але ще неповний, і кожен метод змушений рахуватися з null. Звідси й вузька ніша сеттера: справді необовʼязкові речі на кшталт логера з NullLogger за замовчуванням (LoggerAwareInterface з PSR-3), успадковані класи, створення яких ви не контролюєте, і розрив циклу як тимчасовий пластир. Побічний ефект конструкторної інʼєкції корисний сам по собі: коли параметрів набирається сім, це вже не проблема стилю, а сигнал, що клас узяв на себе кілька відповідальностей.
Інверсія залежностей додає до цього питання напряму. Мало передати SmtpMailer ззовні, якщо тип параметра теж SmtpMailer: прикладний код і далі залежить від деталі інфраструктури. Тому в сигнатурі стоїть інтерфейс Mailer, і власником цього інтерфейсу є споживач, а не бібліотека транспорту. У модульній структурі це буквально видно по теках: інтерфейс лежить у Domain або Application, реалізація з залежністю від Symfony Mailer чи cURL - в Infrastructure, і стрілка залежності вказує всередину. Зворотний бік такий самий важливий: інтерфейс не потрібен там, де немає ні виходу назовні, ні другої реалізації. Калькулятор знижок, парсер рядка, Money з арифметикою чудово живуть як фінальні класи, а MoneyInterface з єдиним нащадком лише додає файл, який доведеться відкривати на кожному переході «до реалізації».
Контейнер зʼявляється тоді, коли ручне складання графа перестає вміщатися в голову. По суті це реєстр фабрик плюс автозвʼязування: коли для запитаного ідентифікатора немає зареєстрованої фабрики, контейнер бере ReflectionClass, дивиться типи параметрів конструктора і рекурсивно будує кожен із них, як у прикладі коду. Далі починаються відмінності реалізацій. Laravel і PHP-DI роблять рефлексію в рантаймі (PHP-DI вміє кешувати результат через enableCompilation()), Symfony компілює конфігурацію в згенерований PHP-клас під час білду, тому в проді жодної рефлексії вже немає, а помилка привʼязки випливає при компіляції контейнера, а не на живому запиті. Спільна для всіх межа: автозвʼязування виводить тільки те, що є в типах. Інтерфейс сам себе створити не може, string $dsn не має де взяти значення, і обидва випадки закриваються явною фабрикою. Стандарт PSR-11 описує лише два методи, get() і has(), для тих, хто обʼєкти споживає; спосіб реєстрації кожен контейнер визначає сам, тому переносити конфігурацію між ними доводиться руками.
Місце контейнера - композиційний корінь: bootstrap, service provider, DI-конфіг. Усередині класів застосунку його не має бути видно, інакше DI непомітно вироджується в service locator з усіма наслідками, описаними у follow-up. Так само не варто тягнути через контейнер речі, які нічого не приховують: DTO, сутності, value objects створюються звичайним new, а маленький скрипт чи одноразова консольна команда живе без контейнера взагалі. Окремий випадок для суперечки - new DateTimeImmutable() усередині методу: сам по собі він нешкідливий, поки тест не почне залежати від поточного часу; тоді джерело часу виноситься в залежність, і в PHP для цього вже є стандартний Psr\Clock\ClockInterface (PSR-20). Правило, яке добре працює на практиці: інжектимо те, що ходить назовні або має змінюватися в тестах, решту створюємо на місці.
declare(strict_types=1);
interface Mailer
{
public function send(string $to, string $text): void;
}
final class SmtpMailer implements Mailer
{
public function __construct(private string $dsn) {} // скаляр контейнер не вгадає
public function send(string $to, string $text): void { /* SMTP */ }
}
final class RegisterUser
{
// залежність у конструкторі: обʼєкт або зібраний повністю, або не існує
public function __construct(private Mailer $mailer) {}
public function __invoke(string $email): void
{
$this->mailer->send($email, 'Вітаємо на борту');
}
}
final class Container
{
/** @var array<string, callable(self): object> */
private array $factories = [];
/** @var array<string, object> */
private array $shared = [];
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
}
public function get(string $id): object
{
return $this->shared[$id] ??= isset($this->factories[$id])
? ($this->factories[$id])($this)
: $this->autowire($id);
}
/** Автозвʼязування: читаємо типи конструктора і рекурсивно будуємо аргументи */
private function autowire(string $id): object
{
$args = array_map(
fn (ReflectionParameter $p) => $this->get((string) $p->getType()),
(new ReflectionClass($id))->getConstructor()?->getParameters() ?? [],
);
return new $id(...$args);
}
}
$container = new Container();
$container->set(Mailer::class, static fn () => new SmtpMailer(getenv('MAIL_DSN')));
// RegisterUser ніде не зареєстрований, контейнер зібрав його сам за типом Mailer
($container->get(RegisterUser::class))('dev@phpukraine.com');
Скажіть коротку формулу: «залежність приходить у конструктор, тип параметра це інтерфейс там, де є вихід назовні, а контейнер лише складає граф і не має зʼявлятися в коді класу». Потім наведіть свій приклад тесту, де реальний клієнт API замінили на фейк без жодного mock-фреймворка: це переконує краще за визначення.