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) { /* … */ });