DI-контейнер в Yii 2: рабочие приёмы из практики
Как настроить контейнер зависимостей в Yii 2 так, чтобы классы не тянули за собой Yii::$app, а тесты не превращались в борьбу с синглтонами. Разбор рабочих приёмов на реальных сценариях.

Типичная картина в проекте на Yii 2: сервисный класс внутри метода дёргает Yii::$app->db, Yii::$app->mailer и ещё пару компонентов из глобального объекта приложения. Работает — до тех пор, пока не приходит время написать unit-тест или подменить реализацию. Тогда выясняется, что класс намертво сцеплен с глобальным состоянием, и без поднятия всего приложения его не запустить. DI-контейнер в Yii решает эту проблему, но новички либо не используют его вовсе, либо настраивают так, что становится хуже. Разберём приёмы, которые действительно снимают боль.
Материал для Yii 2.0.x (актуально и для 2.0.40+). Речь про класс yii\di\Container и экземпляр Yii::$container, а также про массив container в конфигурации приложения. Yii 3 (сейчас в разработке отдельными пакетами) устроен иначе — там PSR-11 контейнер по умолчанию, и приёмы отсюда к нему напрямую не переносятся.
Проблема: Yii::$app как скрытая зависимость
Возьмём сервис регистрации пользователя. Наивная реализация:
<?php
namespace app\services;
use Yii;
class RegistrationService
{
public function register(string $email, string $password): bool
{
$user = new \app\models\User();
$user->email = $email;
$user->setPassword($password);
// скрытая зависимость: db и mailer берутся из глобального объекта
if (!$user->save()) {
return false;
}
Yii::$app->mailer->compose()
->setTo($email)
->setSubject('Добро пожаловать')
->send();
return true;
}
}По сигнатуре конструктора нельзя понять, что классу нужны база и почта. Чтобы протестировать register(), придётся поднять приложение с сконфигурированными компонентами. Подменить mailer на заглушку без хаков не выйдет. Это и есть та цена, которую платят за Yii::$app внутри логики.
Приём 1: зависимости через конструктор
Yii умеет автоматически подставлять аргументы конструктора по type-hint, если создавать объект через контейнер. Перепишем сервис так, чтобы зависимости были явными:
<?php
namespace app\services;
use yii\mail\MailerInterface;
class RegistrationService
{
private MailerInterface $mailer;
// контейнер сам подставит mailer по type-hint интерфейса
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function register(string $email, string $password): bool
{
$user = new \app\models\User();
$user->email = $email;
$user->setPassword($password);
if (!$user->save()) {
return false;
}
$this->mailer->compose()
->setTo($email)
->setSubject('Добро пожаловать')
->send();
return true;
}
}Теперь зависимость видна в сигнатуре. Осталось объяснить контейнеру, какой конкретно класс подставлять под MailerInterface.
Правило простое: если класс просит зависимость через конструктор, о нём знают все, кто читает код. Если тянет её из Yii::$app — знает только тот, кто открыл тело метода.Приём 2: привязка интерфейса к реализации в конфиге
Массив container в конфигурации приложения — это то, что попадает в Yii::$container при старте. Привязки удобнее держать там, а не разбрасывать вызовы Yii::$container->set() по коду:
<?php
// config/web.php (или common для advanced-шаблона)
return [
// ...
'container' => [
'definitions' => [
// при запросе интерфейса вернём компонент приложения
\yii\mail\MailerInterface::class => function () {
return Yii::$app->mailer;
},
],
'singletons' => [
// один экземпляр на всё приложение
\app\services\PaymentGateway::class => [
'class' => \app\services\StripeGateway::class,
'apiKey' => getenv('PAYMENT_API_KEY'),
],
],
],
];Разница между definitions и singletons критична для понимания. Определение из definitions создаёт новый объект на каждый get(). Определение из singletons создаётся один раз и переиспользуется. Ошибка новичка — положить в definitions сервис с внутренним состоянием и удивляться, почему кеш внутри не работает: каждый вызов возвращает свежий объект.
Приём 3: получаем объект через контейнер, а не через new
Автоподстановка работает только когда объект создаётся контейнером. Три рабочих способа:
Yii::createObject(RegistrationService::class)— самый частый вариант в коде приложения;- инъекция в конструктор другого класса, который сам создан контейнером — тогда цепочка разворачивается автоматически;
- в контроллерах Yii с версии 2.0.36 поддерживает action injection: аргументы метода-действия по type-hint подставляются из контейнера.
Пример action injection в контроллере:
<?php
namespace app\controllers;
use yii\web\Controller;
use app\services\RegistrationService;
class SignupController extends Controller
{
// RegistrationService придёт из контейнера со всеми своими зависимостями
public function actionIndex(RegistrationService $service)
{
// ... $service уже готов к работе
return $this->render('index');
}
}Сравнение способов регистрации зависимости
| Способ | Когда применять | Цена |
|---|---|---|
set() с именем класса | Простая привязка интерфейс → класс | Новый объект на каждый запрос из контейнера |
setSingleton() | Сервис без состояния запроса, тяжёлая инициализация | Живёт до конца процесса; в долгих воркерах копит состояние |
| Замыкание (Closure) | Нужна логика при создании, доступ к другим компонентам | Замыкание вызывается каждый раз; следите, что внутри |
Массив-конфиг ['class' => ..., 'prop' => ...] | Нужно задать свойства объекта при создании | Свойства пишутся после конструктора, не в него |
Когда что выбирать
Критерий один — состояние и стоимость создания.
- Нет состояния, дёшево создать (форматтеры, валидаторы) — обычное определение через
set(). Лишний объект на запрос ничего не стоит. - Дорого создать, состояние общее (пул соединений, HTTP-клиент с настроенным базовым URL) —
setSingleton(). - Состояние привязано к запросу (текущий пользователь, корзина) — никогда не синглтон. В обычном PHP-FPM процесс живёт один запрос и разница незаметна, но под RoadRunner или Swoole синглтон переживёт запрос и утечёт данными между пользователями.
Что ломается на практике
Типичные ошибки, которые видно на ревью и в логах:
- Циклическая зависимость. Класс A в конструкторе просит B, B просит A. Контейнер уйдёт в рекурсию и упрётся в лимит стека. Лечится разрывом цикла: одну из зависимостей передавать через сеттер (setter injection) либо вынести в отдельный класс.
- Скалярный аргумент без дефолта. Если в конструкторе есть
string $apiKeyбез значения по умолчанию, контейнер не знает, что подставить, и бросит исключение. Решение — задать значение в определении через массив['class' => ..., 'apiKey' => ...]или третьим аргументомcreateObject(). - Синглтон с зависимостью от запроса. Сервис-синглтон в конструкторе получает
yii\web\Request. При первом создании захватывается один Request и запоминается навсегда. Под классическим FPM пройдёт, под persistent-воркером сломается. - Привязка не подхватывается. Определение положили в конфиг консольного приложения, а падает в веб-части (или наоборот). В advanced-шаблоне проверьте, что
containerпрописан вcommon/config, если привязка нужна везде.
Как понять, что зависимость реально идёт через контейнер: временно бросьте исключение в конструкторе сервиса и убедитесь, что оно всплывает при вызове createObject(), а не при первом обращении к методу. Если объект создаётся обычным new где-то в коде, автоподстановка не сработает — контейнер тут ни при чём.
Что делать дальше
Пройдите по проекту grep-ом по Yii::$app-> внутри каталогов services и components — это карта скрытых зависимостей, которые стоит вынести в конструкторы. Начните с классов, которые сложнее всего тестировать. После перевода на инъекцию через конструктор напишите первый unit-тест с подменённой заглушкой — если он запускается без поднятия приложения, рефакторинг удался. Инвалидация и время жизни синглтонов под persistent-воркерами (RoadRunner, Swoole) — отдельная большая тема, здесь мы её не разворачивали.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Yii 2 Guide — Dependency Injection Container, Yii 2 API — yii\di\Container
Частые вопросы
Работают ли эти приёмы в Yii 3?
Контейнер бросает исключение о недопустимом аргументе конструктора — что делать?
Почему автоподстановка не срабатывает, хотя я всё настроил?
Можно ли использовать синглтоны под RoadRunner или Swoole?
Где лучше держать привязки — в конфиге или вызывать set() в коде?
Как подменить реальный сервис на заглушку в тестах?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.