DI-контейнер
Контейнер — це мозок застосунку Lift. Він знає, як конструювати ваші сервіси, тож вам ніколи не доводиться писати new для чогось, що має залежності. Він також дозволяє підміняти реалізації в тестах, не торкаючись продакшен-коду.
Ментальна модель: контейнер — це Map<ім’я-класу, фабрика>. Ви запитуєте
get(MyService::class), він з’ясовує, що потрібноMyService, будує це спершу, потім будуєMyServiceі віддає вам. Якщо ви не сказали йому як, він використовує автозв’язування — рефлексію конструктора класу.
Найпростіше можливе застосування
class Mailer
{
public function __construct(private readonly string $host = 'smtp.example.com') {}
}
class WelcomeService
{
public function __construct(private readonly Mailer $mailer) {}
}
// Просто запитайте його — контейнер побудує дерево залежностей за вас.
$svc = $app->make(WelcomeService::class);
// ↑ Lift бачить, що WelcomeService потрібен Mailer.
// Mailer'у не потрібні інші класи, лише рядок зі значенням за замовчуванням.
// Lift конструює Mailer, потім WelcomeService(mailer) і повертає його.
Вам не потрібно реєструвати жоден із класів. Обидва конкретні і мають конструктори, які контейнер може задовольнити → їх обробляє автозв’язування.
Коли потрібно реєструвати клас?
Три ситуації:
| Ситуація | Що робити |
|---|---|
| Прив’язка інтерфейс → конкретний клас | $app->bind(I::class, Concrete::class) |
| Конструктору потрібні значення конфігурації (DSN, секрет тощо) | $app->bind(X::class, fn() => new X(...)) |
| Екземпляр дорогий — будувати лише раз за запит | $app->singleton(X::class, ...) |
| У вас уже є побудований екземпляр | $app->instance(X::class, $obj) |
bind() — фабрика викликається щоразу
// Інтерфейс → клас
$app->bind(LoggerInterface::class, FileLogger::class);
// Фабрика-замикання (з аргументами)
$app->bind(Mailer::class, fn() => new Mailer(
host: $_ENV['MAIL_HOST'],
port: (int) $_ENV['MAIL_PORT'],
));
// Фабрика, що використовує сам контейнер
$app->bind(UserRepository::class, function (Container $c) {
return new UserRepository($c->get(Database::class));
});
Кожен $app->make(Mailer::class) запускає фабрику заново, даючи вам свіжий екземпляр.
singleton() — розв’язати раз, повторно використовувати
$app->singleton(Database::class, fn() => new Database($_ENV['DB_DSN']));
// Автозв’язуваний синглтон (без фабрики) — Lift усе одно кешує його
$app->singleton(UserRepository::class);
$app->make(Database::class) повертає той самий екземпляр на кожен виклик, доки запит не завершиться.
Синглтон у Lift — на процес під час роботи у довгоживучому SAPI (RoadRunner, Swoole, ReactPHP) і на запит під PHP-FPM. Не зберігайте стан рівня запиту всередині синглтона.
instance() — уже побудований об’єкт
$config = new Config(['debug' => true]);
$app->instance(Config::class, $config);
$app->make(Config::class) === $config; // true, завжди
Корисно для: конфігів, зібраних під час завантаження, моків у тестах, сторонніх об’єктів, які ви сконструювали поза Lift.
Автозв’язування — магія у деталях
Для кожного параметра конструктора контейнер:
- Перевіряє, чи збігається явне перевизначення за іменем (
$app->make(X::class, ['port' => 8080])). - Дивиться на підказку типу параметра. Якщо це не вбудований клас/інтерфейс:
- Чи прив’язаний він у контейнері? Використати це.
- Інакше, чи конкретний клас і його можна створити? Рекурсивно автозв’язати.
- Якщо тип nullable, відкотитися до
null. - Якщо параметр необов’язковий (має значення за замовчуванням), використати значення за замовчуванням.
- Інакше викинути
ContainerExceptionіз точним зазначенням параметра та класу.
Конкретний приклад:
class OrderService
{
public function __construct(
private readonly OrderRepository $orders, // автозв’язаний (або прив’язаний)
private readonly Mailer $mailer, // автозв’язаний (або прив’язаний)
private readonly int $maxItems = 100, // примітив зі значенням за замовчуванням → 100
) {}
}
// Просто працює. Реєстрація не потрібна, якщо тільки OrderRepository не інтерфейс.
$svc = $app->make(OrderService::class);
Перевизначити один конкретний параметр у місці виклику:
$svc = $app->make(OrderService::class, ['maxItems' => 50]);
Примітивні параметри без значення за замовчуванням фатальні — у контейнера немає способу вгадати
string $dsn. Або прив’яжіть фабрику, або надайте перевизначення.
Впровадження в обробниках маршрутів
Вкажіть тип будь-чого, що контейнер може розв’язати, поряд із Request:
$app->get('/orders', function (Request $req, OrderService $svc) {
return $svc->all();
});
Request доступний завжди — Lift впроваджує поточний об’єкт запиту, навіть якщо він не «зареєстрований».
Працює і в методах контролерів:
class OrderController
{
public function __construct(private readonly OrderService $svc) {}
public function index(Request $req): array
{
return $this->svc->all();
}
}
$app->get('/orders', [OrderController::class, 'index']);
Автозв’язуються і сам клас контролера, і параметри методу.
make() — пряме розв’язання
$repo = $app->make(UserRepository::class);
// З іменованими перевизначеннями
$svc = $app->make(ReportService::class, ['month' => 5]);
make() — це API найнижчого рівня; під капотом $app->get(...), обробники [Class::class, 'method'] та розв’язання middleware усі проходять через нього.
call() — викликати будь-який callable з впровадженням
Іноді у вас є наявний callable, і ви просто хочете, щоб контейнер заповнив його параметри:
$container = $app->container();
// Замикання
$result = $container->call(fn(Database $db) => $db->query('SELECT 1'));
// [Class, 'method']
$result = $container->call([ReportGenerator::class, 'monthly'], ['month' => 5]);
// Уже побудований екземпляр
$result = $container->call([$generator, 'monthly'], ['month' => 5]);
has() — перевірити, чи розв’язуване щось
$c = $app->container();
$c->has(LoggerInterface::class); // true, якщо прив’язаний
$c->has(NotRegistered::class); // true, якщо клас існує і автозв’язуваний; інакше false
Корисно у бібліотеках, які хочуть опційно використовувати сервіс, якщо користувач його надав.
Відповідність PSR-11
Container реалізує Psr\Container\ContainerInterface. Його можна передати будь-якій PSR-11-сумісній бібліотеці:
$psr11 = $app->container(); // Psr\Container\ContainerInterface
$svc = $psr11->get(MyThing::class);
Він викидає правильні типи винятків PSR-11:
Lift\Exception\ContainerNotFoundException(Psr\Container\NotFoundExceptionInterface)Lift\Exception\ContainerException(Psr\Container\ContainerExceptionInterface)
Циклічні залежності
Якщо A залежить від B, а B залежить від A, контейнер виявляє це й викидає:
Lift\Exception\ContainerException:
Circular dependency detected while resolving [App\A]
Авторозв’язання немає (ви не можете розірвати цикл, не обравши сторону). Виправлення архітектурне — розірвіть цикл, виділивши третій клас, або використовуючи сетер замість конструктора.
Заміна сервісів у тестах
$app = new App();
// Справжні прив’язки:
$app->singleton(Mailer::class, fn() => new SmtpMailer($_ENV['MAIL_DSN']));
// У налаштуванні тесту:
$app->instance(Mailer::class, new InMemoryMailer());
$response = $app->handle($request);
instance() і bind() мовчки перезаписують одне одного — перемагає остання реєстрація.
Нотатки про продуктивність
- Рефлексія кешується — кожен клас рефлексується рівно один раз на процес (кеш
static). Під OPcache + персистентним SAPI ви платите вартість рефлексії один раз під час завантаження і більше ніколи. - Синглтони заощаджують роботу конструктора на кожному наступному розв’язанні.
- Контейнер не робить розбору анотацій, не генерує проксі, не компілює. Усе — звичайний рантайм-PHP. Компроміс: трохи повільніше, ніж компільований контейнер на кшталт Symfony, але нуль кроків збірки.
Хочете ще швидший старт? Заздалегідь «запаліть» синглтони, які точно будуть зачеплені:
$app->container()->get(Database::class);
$app->container()->get(Logger::class);
(Тепер вони побудовані один раз під час завантаження, а не на критичному шляху першого запиту, якому вони потрібні.)
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
Cannot resolve parameter $foo of type [App\X] |
App\X — інтерфейс без прив’язки |
$app->bind(X::class, ConcreteX::class). |
Cannot resolve untyped required parameter $dsn |
Конструктор приймає string без значення за замовчуванням |
Прив’яжіть фабрику: $app->bind(X::class, fn() => new X(dsn: ...)). |
| Синглтон бачить старий стан | Ви зберегли в ньому змінний стан (погана практика під FPM) | Перенесіть стан рівня запиту в атрибути Request. |
| Тестовий мок не використовується | Зареєстрований через bind() після того, як щось уже його розв’язало (наприклад, глобальний middleware у $app->use()) |
Використовуйте instance() до будь-якого розв’язання або singleton() (зареєстрований → розв’язується заново). |
Шпаргалка
// Реєстрація
$app->bind ($abstract, $concrete|$factory); // свіжий кожен виклик
$app->singleton($abstract, $concrete|$factory|null); // розв’язати раз
$app->instance ($abstract, $object); // готовий
// Розв’язання
$x = $app->make ($abstract, $overrides = []);
$x = $app->container()->get($abstract); // PSR-11
$ok = $app->container()->has($abstract);
// Викликати callable з впровадженням
$app->container()->call($callable, $overrides = []);
// Більшість застосувань: просто вкажіть тип і дайте Lift розібратися
$app->get('/x', function (Request $req, MyService $svc) { /* … */ });