Введение
Реализация PSR-11 с компиляцией графа зависимостей на этапе сборки.
Требования: PHP 8.2+
Преимущества
- Скорость — нет накладных расходов в рантайме: контейнер готов сразу
- Оптимизация — работает напрямую с opcache, не требует кэширования
- Безопасность — ошибки конфигурации обнаруживаются на этапе сборки
- Гибкость — генерируйте код при деплое, в CI/CD или при первом запуске, как вам удобно
Важно
Сборка обязательна — необходимо организовать этап генерации кода. Это нетипично для PHP, но именно это обеспечивает высокую производительность.
Начало работы
Этот раздел — пошаговое руководство, которое покажет, как настроить и использовать Cekta\DI.
Без DI-контейнера
Давайте представим простейший проект с классом Example, зависящим от подключения к базе данных (PDO).
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example
{
public function __construct(
private \PDO $db,
) {
}
public function payload(): void
{
// ... используйте $db ...
}
}
index.php — ручное создание зависимости:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$pdo = new \PDO(getenv('DB_DSN') ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite');
$example = new \App\Example($pdo);
$example->payload();
Проблема: каждый, кто использует Example, должен вручную создавать и передавать \PDO. При росте проекта это быстро становится хаосом.
С DI-контейнером
Теперь то же самое, но зависимости управляются централизованно через Cekta\DI.
1. Установка
composer require cekta/di
2. Конфигурация проекта
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct(public readonly array $env = getenv())
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
params: [
\PDO::class . '$dsn' => $env['DB_DSN'] ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite',
],
);
}
public function definition(): array
{
return [
'entries' => [\App\Example::class],
'alias' => [],
'singletons' => [],
'factories' => [],
];
}
}
3. Компиляция контейнера
bin/compile.php — скрипт для генерации сконфигурированного контейнера:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$project = new \App\Project();
$compiler = new \Cekta\DI\ContainerCompiler();
file_put_contents($project->filename, $compiler->compile($project));
echo "{$project->filename} was generated" . PHP_EOL;
Запустите скрипт один раз, чтобы создать Container.php:
php bin/compile.php
Скрипт создаётся один раз и переиспользуется при необходимости перегенерации.
Он читает конфигурацию из Project и генерирует готовый файл контейнера.
4. Использование контейнера
Скомпилированный контейнер можно использовать в приложении:
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$project = new \App\Project();
$factory = new \Cekta\DI\ContainerFactory();
$container = $factory->create($project);
$example = $container->get(\App\Example::class);
$example->payload();
ContainerFactory создаёт экземпляр контейнера и подставляет параметры из проекта.
Зависимости разрешаются автоматически — \PDO будет передан в конструктор Example без ручной настройки.
Преимущества
Однажды сконфигурированная зависимость доступна всем, кто в ней нуждается — без лишних настроек.
Допустим, у вас есть Example, ReportGenerator и ConsoleCommand, всем нужен \PDO. Вам не нужно настраивать подключение к базе каждый раз: достаточно описать параметр один раз в конфигурации, а контейнер сам внедрит его в любые классы, которым он требуется.
Это особенно удобно в команде: разработчик пишет код, опираясь на типизированные зависимости, и не заботится о деталях их создания — всё настроено централизованно.
Проект
Проект конфигурируется в классе, наследуемом от AbstractProject.
В конструкторе передаются данные (путь генерации, параметры), а в definition() — зависимости и их поведение.
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct(public readonly array $env = getenv())
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
params: [
\PDO::class . '$dsn' => $env['DB_DSN'] ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite',
],
);
}
public function definition(): array
{
return [
'entries' => [
\App\Example::class,
],
'alias' => [
\Psr\Log\LoggerInterface::class => \Monolog\Logger::class,
],
'singletons' => [
\App\Service\Cache::class,
],
'factories' => [
\App\Service\RequestHandler::class,
],
];
}
}
Конструктор
| Параметр | Назначение | Подробнее |
|---|---|---|
filename | Путь к файлу сгенерированного контейнера | — |
fqcn | Полное квалифицированное имя класса контейнера | — |
params | Скалярные значения для аргументов конструкторов | Параметры |
Зависимости
В методе definition() задаются зависимости и их поведение:
| Ключ | Назначение | Подробнее |
|---|---|---|
alias | Привязка интерфейсов и абстракций к реализациям | Алиасы |
singletons | Один экземпляр на весь PHP-процесс | Жизненный цикл |
factories | Новый экземпляр при каждом get() | Жизненный цикл |
💡 Классы, указанные в
entries, по умолчанию ведут себя как scoped — один экземпляр на контейнер. Полное сравнение жизненных циклов — в разделе «Управление жизненным циклом».
Параметры
Параметры — механизм задания конкретных значений для аргументов конструктора. Они позволяют:
- передавать встроенные типы (
string,int,arrayи т.д.) - переопределять значения по умолчанию
- передавать конкретные экземпляры объектов
Значения параметров задаются в конструкторе класса, наследуемого от AbstractProject.
Имена параметров
Каждый параметр имеет имя — ключ в конфигурации.
Имена могут быть глобальными и локальными.
💡 Рекомендация. По умолчанию используйте глобальные имена. Они делают конфигурацию предсказуемой — одна настройка работает для всех потребителей. Локальные имена — для точечного переопределения.
Пример
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example
{
public function __construct(
public string $firstName,
public string $lastName,
) {}
}
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct()
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
params: [
\App\Example::class . '$firstName' => 'admin',
\App\Example::class . '$lastName' => 'secret',
],
);
}
public function definition(): array
{
return [
'entries' => [\App\Example::class],
'alias' => [],
'singletons' => [],
'factories' => [],
];
}
}
Полный пример с компиляцией и использованием контейнера описан в Начале работы.
Отложенные значения
Параметры вычисляются каждый раз в runtime (значения runtime переопределяют значения параметров с compile).
Если нужно отложить вычисление до рантайма — см. Отложенные значения.
Алиасы
Алиасы — маппинг абстрактных типов (интерфейсов, абстрактных классов) на конкретные реализации.
Когда использовать
- Привязка интерфейса к реализации — контейнер видит тип
Loggerи создаётConsoleLogger. - Выбор наследника — вместо базового класса подставляется конкретный наследник.
- Замена зависимостей — можно подменить реализацию, не трогая код потребителя.
Пример
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example
{
public function __construct(
public Logger $logger,
) {
}
}
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct()
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
);
}
public function definition(): array
{
return [
'entries' => [Example::class],
'alias' => [
// Глобальный алиас — для всех, кто просит Logger
Logger::class => ConsoleLogger::class,
],
'singletons' => [],
'factories' => [],
];
}
}
Использование скомпилированного контейнера описано в Начале работы.
⚠️ Алиасы определяются на этапе компиляции. Для изменения маппинга потребуется пересобрать контейнер.
Отложенные значения (Lazy)
Большинство параметров вычисляются на этапе компиляции контейнера. Однако есть сценарии, когда
значение нужно получить только в момент использования. Для таких случаев в библиотеке
предусмотрен интерфейс Lazy, позволяющий отложить вычисление до рантайма.
Lazy-параметры полезны, когда:
- Нужно создать объект через callback, например, внедряя зависимости в методы после создания.
- Нужно получить ссылку на текущий контейнер для реализации паттерна Service Locator.
- Нужно сгенерировать значение на основе других параметров, например, DSN-строку подключения
к БД из
db_type,db_host,db_nameи т. д.
Подробнее о параметрах — в разделе Параметры.
Интерфейс Lazy
<?php
declare(strict_types=1);
namespace Cekta\DI;
use Psr\Container\ContainerInterface;
/**
* @external
*/
interface Lazy
{
public function load(ContainerInterface $container): mixed;
}
Интерфейс Lazy помечен как external —
он предназначен для использования пользователями библиотеки. Вы можете создать собственную
реализацию, если стандартные решения не покрывают ваши потребности.
Все реализации Lazy вызывают метод load() в момент разрешения зависимости — уже внутри
скомпилированного контейнера. Это позволяет получить доступ к экземпляру ContainerInterface
и выполнить произвольную логику.
В библиотеке есть две готовые реализации.
Closure
Принимает callback-функцию и вызывает её в момент разрешения зависимости. Позволяет реализовать любую логику создания и возврата значения.
Container
Возвращает сам контейнер при вызове load(). Используется, когда классу нужен доступ к контейнеру
для разрешения зависимостей, например, при реализации паттерна Service Locator.
Примеры
Пример 1: Внедрение зависимостей через метод
Обычно зависимости передаются через конструктор. Однако бывают ситуации, когда требуется внедрить зависимость в метод или выполнить дополнительную инициализацию после создания объекта.
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example
{
private \PDO $connection;
public function setPdo(\PDO $connection)
{
$this->connection = $connection;
}
}
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct(private array $env)
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
params: [
\App\Example::class . '$connection' => new \Cekta\DI\Lazy\Closure(
function (\Psr\Container\ContainerInterface $c) {
$example = new \App\Example();
$example->setPdo($c->get(\PDO::class));
return $example;
}
),
],
);
}
public function definition(): array
{
return [
'entries' => [\App\Example::class, \PDO::class],
'alias' => [],
'singletons' => [],
'factories' => [],
];
}
}
Пример 2: Внедрение ContainerInterface
Иногда классу требуется доступ к контейнеру для разрешения зависимостей. Например, при реализации паттерна Service Locator.
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example
{
public function __construct(
private \Psr\Container\ContainerInterface $container
) {
}
}
src/Project.php
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function __construct()
{
parent::__construct(
filename: __DIR__ . '/../Container.php',
fqcn: 'App\Container',
params: [
\Psr\Container\ContainerInterface::class => new \Cekta\DI\Lazy\Container(),
],
);
}
public function definition(): array
{
return [
'entries' => [\App\Example::class],
'alias' => [],
'singletons' => [],
'factories' => [],
];
}
}
Рекомендации
- Если параметры используются внутри
Closure, добавляйте их вentriesдля гарантии доступности - Предпочтительный способ внедрения зависимостей — конструктор. Используйте
Lazyтолько когда это невозможно
Жизненный цикл
В долгоживущих процессах — приложениях на RoadRunner, FrankenPHP, фоновых workers или консольных командах, обрабатывающих множество задач — важно контролировать, сколько экземпляров сервиса создаётся и где они живут.
Для этого в библиотеке предусмотрены три типа жизненного цикла. Их можно применять к любым зависимостям: entries, params, alias и autowiring.
Обзор
| Тип | Описание | Когда использовать |
|---|---|---|
| Scoped | Один экземпляр на контейнер | Обработка запросов, пользовательские сессии, изоляция данных |
| Singleton | Один экземпляр на весь PHP-процесс | Конфигурация, подключения к БД, кеши |
| Factory | Новый экземпляр при каждом вызове get() | Stateless-сервисы, DTO, временные данные |
💡 Классы, указанные в
entries, по умолчанию ведут себя как scoped. Указывать их в отдельных разделах не нужно.
Конфигурация
Жизненный цикл определяется в методе definition() класса AbstractProject:
<?php
declare(strict_types=1);
namespace App;
class Project extends \Cekta\DI\AbstractProject
{
public function definition(): array
{
return [
'entries' => [
\App\HttpController::class, // Scoped
],
'singletons' => [
\App\Database::class, // Singleton
],
'factories' => [
\App\HttpRequest::class, // Factory
],
];
}
}
Пример
Рассмотрим, как ведёт себя каждый тип при вызове get() на одном и том же и на разных контейнерах:
<?php
$container1 = new \App\Container();
$container2 = new \App\Container();
$scoped1 = $container1->get(\App\Scoped::class);
$scoped2 = $container1->get(\App\Scoped::class);
// $scoped1 === $scoped2
$singleton1 = $container1->get(\App\Singleton::class);
$singleton2 = $container2->get(\App\Singleton::class);
// $singleton1 === $singleton2
$factory1 = $container1->get(\App\Factory::class);
$factory2 = $container2->get(\App\Factory::class);
// $factory1 !== $factory2
// $factory1 !== $factory2
Сравнение
| Тип | Внутри одного контейнера | Между разными контейнерами | Когда использовать |
|---|---|---|---|
| Scoped | Один объект | Разные объекты | Обработка запросов, пользовательские сессии, изоляция данных |
| Singleton | Один объект | Один объект | Конфигурация, подключения к БД, кеши |
| Factory | Разные объекты | Разные объекты | Stateless-сервисы, DTO, временные данные |
Важно
- Конфликты — один и тот же класс нельзя указать одновременно как
SingletonиFactory - Производительность —
Factoryсоздаёт наибольшую нагрузку,Singleton— наименьшую - Потокобезопасность —
Singletonдолжен быть потокобезопасным в многопоточных средах
Полное описание параметров конфигурации — Проект.
Имена зависимостей
Каждая зависимость в конфигурации DI идентифицируется по уникальному имени. Знание правил
формирования этих имён — ключ к правильной настройке params и alias.
Формирование имени
Имя зависимости строится из сигнатуры конструктора потребляющего класса. Разберём основные случаи на примере:
<?php
namespace App;
class Example
{
public function __construct(
string $locale, // примитивный тип
Logger $logger, // тип-класс
string|Logger $mix, // объединённый тип
?Logger $optional, // nullable
Logger ...$logs, // вариативный аргумент
) {}
}
Для каждого аргумента генерируются два имени:
Глобальное имя
Используется для регистрации значения во всём проекте.
| Аргумент | Глобальное имя |
|---|---|
string $locale | "locale" |
Logger $logger | "App\Logger" |
string|Logger $mix | "string|App\Logger" |
?Logger $optional | "?App\Logger" |
Logger ...$logs | "...App\Logger" |
Правило: тип аргумента, взятый как есть — с полным namespace, модификаторами ? и ....
Для скалярных типов (string, int, bool, float, array, object, callable, iterable) —
имя равно имени типа в кавычках.
Локальное имя
Используется для точечной настройки одного конкретного аргумента.
| Аргумент | Локальное имя |
|---|---|
string $locale | App\Example::class . '$locale' |
Logger $logger | App\Example::class . '$logger' |
string|Logger $mix | App\Example::class . '$mix' |
?Logger $optional | App\Example::class . '$optional' |
Logger ...$logs | '...' . App\Example::class . '$logs' |
Правило: ClassName::class . '$argName'. Для вариативных аргументов — с префиксом '...' ..
Глобальное или локальное?
Глобальное имя
Применяется ко всем потребителям сразу. Удобно для привязки интерфейсов и общих параметров:
'params' => [
'locale' => 'ru_RU', // все locale-параметры во всём проекте
],
'alias' => [
Logger::class => Monolog\Logger::class, // все, кто просит Logger, получат Monolog
],
Локальное имя
Применяется точечно, только к одному аргументу одного класса. Удобно для переопределения:
'params' => [
App\Example::class . '$locale' => 'en_GB', // только для Example::$locale
],
Что выбрать?
| Ситуация | Рекомендуемый подход |
|---|---|
| Маппинг интерфейса → реализация | Глобальный алиас |
| Общий параметр (locale, версия API) | Глобальный параметр |
| Переопределение для конкретного класса | Локальное имя |
| Примитивный параметр уникального сервиса | Локальное имя |
💡 По умолчанию используйте глобальные имена. Они делают конфигурацию предсказуемой — одна настройка работает для всех потребителей. Локальные имена — для точечного переопределения, когда это действительно нужно.
Как разрешается зависимость
Во время сборки для каждой зависимости из entries строится полный граф вложенных зависимостей.
Построение графа происходит только на этапе компиляции — скомпилированный контейнер уже содержит готовую структуру, без рефлексии в рантайме.
Autowiring в конструктор
Механизм автоматического анализа аргументов конструктора для определения требуемых зависимостей.
На основе аргументов конструктора формируются имена зависимостей. Если для аргумента
найдено локальное имя в params или alias, оно используется вместо глобального. Если аргумент имеет
значения по умолчанию, необязательные аргументы без явного значения пропускаются.
Остальные аргументы рекурсивно разрешаются по тем же правилам — через params, alias
или снова через autowiring.
Если аргумент указывает на несуществующий класс или создать экземпляр невозможно (абстрактный класс, интерфейс без алиаса), будет выброшено исключение с детальным описанием проблемы.
Приоритет разрешаемых зависимостей
Когда в конфигурации указаны разные значения для одного и того же имени, система применяет следующий порядок приоритета (от высшего к низшему):
- Локальное имя из
params - Глобальное имя из
params - Локальное имя из
alias - Глобальное имя из
alias - Значение по умолчанию
- Autowiring в конструктор
Рекомендация: используйте глобальные имена для большинства случаев. Они делают конфигурацию предсказуемой — один алиас или параметр применяется ко всем потребителям. Локальные имена доступны для точечного переопределения, но их стоит применять осознанно, когда глобальная настройка не подходит конкретному потребителю.
Поддержка
Если у вас остались вопросы по текущей документации или есть проблема с настройкой в вашем проекте, вы можете:
- Создать issue на русском или английском языке.
- Написать в русскоязычный чат.
Также здесь можно оставить предложения по улучшению библиотеки.
💡 Проект открыт для pull request’ов с улучшениями!