Содержание
- Сначала главное: что такое контейнер и провайдер
- Проблема, которой не видно на маленьких проектах
- Первое лекарство: внедрение зависимостей
- Проблема, которую породило лекарство
- Что такое контейнер
- Автоматическое разрешение зависимостей
- Привязки
- Контекстные привязки и атрибуты
- Как получить объект из контейнера
- Сервис-провайдеры
- Фасады и вспомогательные функции
- Дополнительные возможности контейнера
- Тестирование
- Типичные ошибки
- Свой мини-контейнер
- Итог
Keynotes
- Контейнер (service container) отвечает за создание объектов и их зависимостей.
- Внедрение зависимостей (dependency injection, DI) означает, что класс получает нужные ему объекты извне, а не создаёт их сам.
- Автоматическое разрешение зависимостей (autowiring) — контейнер читает типы параметров конструктора и сам создаёт нужные объекты, рекурсивно повторяя это для их зависимостей.
- Привязка (binding) нужна, когда контейнеру недостаточно информации: например, когда зависимость является интерфейсом или требует конкретных значений.
- Сервис-провайдер (service provider) — класс, который Laravel запускает при старте приложения; в нём регистрируют такие привязки и выполняют начальную настройку.
- Контекстные атрибуты (contextual attributes) (Laravel 11+) позволяют описать то же правило прямо у параметра конструктора, не заводя привязку в провайдере.
- В большинстве обычных классов достаточно написать тип зависимости в конструкторе. Не нужно регистрировать каждый класс вручную.
- Контейнер — это инструмент сборки объектов. Он не заменяет внедрение зависимостей и не является обязательной частью самого принципа DI.
Почти каждый, кто пишет на Laravel, хотя бы раз слышал: «ну это же контейнер разрешает зависимости». Звучит солидно, но за этой фразой у многих пустота. Давайте её заполним.
Мы пойдём от проблемы к решению: сначала посмотрим, какую боль контейнер лечит, потом разберём, как он устроен внутри, а в конце напишем свой мини-контейнер строк на семьдесят, чтобы магия окончательно перестала быть магией.
1. Сначала главное: что такое контейнер и провайдер
Прежде чем разбирать код, полезно сразу разделить две вещи, которые в Laravel часто упоминают вместе.
Контейнер — это объект, который умеет создавать другие объекты и подставлять им необходимые зависимости.
Сервис-провайдер — это класс, который Laravel запускает при старте приложения, до обработки запроса. Именно в нём приложение объясняет контейнеру, как именно создавать некоторые объекты.
Например, контейнер и без всякой настройки понимает, как создать:
class OrderService
{
public function __construct(
private OrderRepository $orders,
) {}
}
Если OrderRepository можно создать через new (то есть это не интерфейс и не абстрактный класс), параметры его конструктора тоже объявлены типами классов, Laravel соберёт всю цепочку сам.
Важная оговорка, на которой спотыкаются чаще всего: контейнер подставляет зависимости только тем объектам, которые создаёт он сам. Базовый класс при этом роли не играет — наследование от Controller или Job ни на что не влияет. Класс OrderService из примера выше не наследует ничего.
Работает это так.
Есть точки входа, которые Laravel создаёт через контейнер без вашего участия: контроллеры, посредники, консольные команды, слушатели событий, методы handle() у заданий очереди. Дальше цепочка продолжается сама: контроллер объявил в конструкторе параметер типа OrderService — значит, инстанс OrderService тоже создаёт контейнер, а раз так, подставится и его OrderRepository.
Глубина не ограничена и лежать эти классы могут где угодно, например, в созданной разработчиком папке app/Services.
Рвётся цепочка ровно в одном случае — когда объект создаёте вы сами:
// Контейнер не участвует: все аргументы нужно передать руками
$service = new OrderService(new OrderRepository());
// Просим контейнер — зависимости подставятся сами
$service = app(OrderService::class);
Отсюда и практическая рекоммендация: для классов с зависимостями не пишите new, а объявляйте тип в конструкторе там, куда объект в итоге попадёт.
Но если класс требует строку:
class SmsSender
{
public function __construct(
private string $apiKey,
) {}
}
контейнер не знает, какую именно строку нужно передать. В этом случае мы должны зарегистрировать привязку в сервис-провайдере:
$this->app->bind(SmsSender::class, function () {
return new SmsSender(config('services.sms.key'));
});
То есть схема очень простая:
сервис-провайдер регистрирует правила → контейнер использует эти правила → приложение получает готовые объекты.
Провайдер при этом не является самим контейнером и не создаёт зависимости вместо него. Его задача — настроить приложение и зарегистрировать в контейнере нужные правила.
Теперь посмотрим, зачем вообще понадобился такой механизм.
Четыре понятия, которые важно не смешивать
Их легко запутать, поэтому зафиксируем:
- внедрение зависимостей — объект получает то, что ему нужно, извне;
- контейнер умеет создавать и находить эти объекты;
- автоматическое разрешение зависимостей — контейнер создаёт объект, читая типы параметров его конструктора, и рекурсивно делает то же самое для каждой зависимости;
- сервис-провайдер — класс, запускаемый при старте приложения, в котором мы настраиваем контейнер и другие части приложения.
Именно поэтому контейнер не стоит воспринимать как «магическую замену new». Его задача — централизовать правила создания объектов там, где это действительно необходимо.
2. Проблема, которой не видно на маленьких проектах
Представим задачу: нужно отправлять уведомления пользователям через какой-нибудь внешний сервис.
class SmsSender
{
public function send(string $phone, string $text): void
{
// обращение к внешнему шлюзу
}
}
class OrderService
{
public function confirm(Order $order): void
{
$sender = new SmsSender();
$sender->send($order->user->phone, 'Заказ подтверждён');
}
}
Работает. Пока не начнёшь задавать вопросы.
Вопрос первый. У SmsSender появился конструктор: ему нужен ключ доступа и адрес шлюза.
class SmsSender
{
public function __construct(
private string $apiKey,
private string $endpoint,
) {}
}
Теперь OrderService обязан знать про ключи и адреса. Это не его дело: он про заказы, а не про настройки внешнего шлюза. Но ему придётся, потому что он сам создаёт объект.
Вопрос второй. Таких мест в коде не одно, а тридцать. Ключ поменялся — правим тридцать файлов.
Вопрос третий. Пришёл бизнес и сказал: в Казахстане шлём не через SMS, а через мессенджер. Значит, нужен другой класс. И теперь по всему коду расползаются if.
Вопрос четвёртый, самый болезненный. Как это протестировать? В тесте OrderService::confirm() реально уйдёт в интернет и потратит деньги. Подменить нечем: объект создаётся жёстко, внутри метода.
Корень всех четырёх проблем один и тот же: класс сам создаёт то, чем пользуется. Он берёт на себя лишнюю ответственность и намертво связывает себя с конкретной реализацией.
3. Первое лекарство: внедрение зависимостей
Лечится это до смешного просто. Пусть класс не создаёт зависимости, а принимает их снаружи, обычно через конструктор.
class OrderService
{
public function __construct(
private SmsSender $sender,
) {}
public function confirm(Order $order): void
{
$this->sender->send($order->user->phone, 'Заказ подтверждён');
}
}
Вот и всё. Это и называется внедрением зависимостей (dependency injection). Никакой философии: объект объявляет, что ему нужно для работы, а кто и как это соберёт — не его забота.
Сделаем ещё шаг и заменим конкретный класс на договорённость (интерфейс):
interface NotificationSender
{
public function send(string $phone, string $text): void;
}
class OrderService
{
public function __construct(
private NotificationSender $sender,
) {}
}
Теперь OrderService вообще не знает, кто там за интерфейсом: SMS, мессенджер или заглушка в тесте. Он знает только, что у объекта есть метод send(). Связанность резко упала.
Но появилась новая проблема.
4. Проблема, которую породило лекарство
Раньше мы писали new OrderService(). Теперь так нельзя:
$service = new OrderService(
new SmsSender(
config('services.sms.key'),
config('services.sms.endpoint'),
)
);
А если у SmsSender есть свои зависимости, а у них — свои, то сборка одного объекта превращается в трёхэтажную конструкцию. И такую конструкцию придётся повторять в каждом контроллере, команде, обработчике очереди.
Мы просто перенесли боль из одного места в другое.
Значит, нужен кто-то, кто возьмёт на себя всю работу по сборке объектов. Один на всё приложение. Кто-то, кому можно сказать: «дай мне OrderService«, а он сам разберётся, что для этого нужно создать и в каком порядке.
Этот «кто-то» и есть контейнер служб (service container).
5. Что такое контейнер
Контейнер — это объект, который умеет создавать другие объекты и разрешать их зависимости.
По сути это механизм сборки объектов плюс хранилище уже созданных экземпляров. Дальше начинаются детали: как контейнер понимает, что создавать, как регистрируются правила и сколько живёт созданный объект.
В Laravel контейнер является объектом приложения (класс Illuminate\Foundation\Application, который наследуется от Illuminate\Container\Container). Получить его можно с помощью глобальной функции app().
Самое главное: вы почти никогда не обращаетесь к контейнеру напрямую. Laravel сам создаёт через него контроллеры, консольные команды, слушатели событий, посредники. Именно поэтому достаточно написать тип в конструкторе контроллера — и объект окажется там сам.
Отдельно стоит сказать про задания очередей (Job): их вы создаёте сами (new SendSms($order) и dispatch()), а между постановкой в очередь и выполнением объект сериализуется и десериализуется. Конструктор задания через контейнер не проходит. Зато метод handle() вызывается через контейнер, поэтому зависимости удобно объявлять именно в нём. Но об этом ниже, в разделе про внедрение в методы.
6. Автоматическое разрешение: где именно происходит «магия»
Пишем:
class OrderController extends Controller
{
public function __construct(
private OrderService $orders,
) {}
}
Мы нигде не сказали контейнеру, что такое OrderService. Откуда он взялся?
Пошагово контейнер делает следующее:
- Смотрит на строку
App\Services\OrderService. Проверяет, нет ли уже созданного ранее объекта . Нет. - Раз объекта нет, но класс существует — попробуем создать его сами.
- Через рефлексию (
ReflectionClass) заглядывает внутрь класса и достаёт конструктор. - Перебирает параметры конструктора и смотрит на их типы.
- Тип
NotificationSender— не скалярный, значит это чья-то зависимость. Рекурсивно повторяем весь алгоритм уже для неё. - Когда все параметры собраны, вызывает
new OrderService(...$параметры).
Ключевое слово здесь — рефлексия: встроенная в PHP возможность программно изучать структуру классов во время выполнения. Контейнер буквально читает сигнатуру вашего конструктора и по типам аргументов понимает, что нужно подставить.
Этот механизм называют автоматическим связыванием (autowiring). Он требует выполнения хотя бы одного из условий для каждого параметра:
- параметр объявлен типом класса, и этот тип разрешим: либо это конкретный создаваемый класс, либо интерфейс, для которого явно указана реализация;
- у параметра есть значение по умолчанию. Тогда контейнер просто возьмёт его и не будет ничего собирать. Это касается и скаляров (
string $prefix = 'sms'), и классов (?LoggerInterface $logger = null).
Как только появляется обязательный параметр, который не подпадает ни под один пункт, автоматика останавливается и важно знать, что сообщения об ошибке будут разными.
Для интерфейса или абстрактного класса без привязки:
Target [App\Contracts\NotificationSender] is not instantiable
while building [App\Http\Controllers\OrderController].
Для обязательного скаляра:
Unresolvable dependency resolving [Parameter #0 [ <required> string $apiKey ]]
in class App\Services\SmsSender
Оба исключения — это Illuminate\Contracts\Container\BindingResolutionException. Разница в тексте мелочь, но именно по нему вы будете искать причину, поэтому полезно различать: первое означает «не знаю, какую реализацию взять», второе — «не знаю, какое значение подставить». И это правильное поведение: угадывать за вас контейнер не должен.
7. Привязки: объясняем контейнеру то, чего он не угадает
Инструкции контейнеру мы указываем в сервис-провайдере (service provider). Обычно это класс App\Providers\AppServiceProvider, метод register().
7.1. bind(). Рецепт создания экземпляра класса.
public function register(): void
{
$this->app->bind(SmsSender::class, function (Application $app) {
return new SmsSender(
config('services.sms.key'),
config('services.sms.endpoint'),
);
});
}
Читается так: «когда у тебя попросят SmsSender, не пытайся собрать его сам, а выполни вот эту функцию».
Замыкание получает два аргумента: сам контейнер и массив параметров, переданных при разрешении (о них в разделе про makeWith). Полная сигнатура выглядит так:
$this->app->bind(SmsSender::class, function (Application $app, array $parameters = []) {
// внутри доступен $app->make(...) для остальных зависимостей
});
Второй аргумент нужен редко, поэтому обычно пишут только первый.
Важная деталь: bind создаёт новый объект при каждом разрешении. Речь именно про каждый вызов make()/app(), а не про HTTP-запрос: попросили пять раз внутри одного запроса — получили пять разных объектов.
7.2. Привязка интерфейса к реализации.
Ради этого контейнеры и придумывали:
$this->app->bind(NotificationSender::class, SmsSender::class);
«Когда кто-то просит интерфейс — давай ему вот этот класс».
Теперь автоматическое связывание снова работает: контейнер, увидев NotificationSender в конструкторе, знает, чем его заменить.
Хотите переключиться на мессенджер? Меняете одну строку в сервис-провайдере, а код приложения не трогаете вообще.
Можно и по условию:
$this->app->bind(NotificationSender::class, function (Application $app) {
return config('services.default_channel') === 'telegram'
? $app->make(TelegramSender::class)
: $app->make(SmsSender::class);
});
7.3. singleton(). Один экземпляр на жизненный цикл контейнера.
$this->app->singleton(CurrencyRates::class, function (Application $app) {
return new CurrencyRates($app->make(HttpClient::class));
});
Первый вызов создаёт объект, последующие обращения к контейнеру возвращают тот же экземпляр. В обычном PHP-приложении с коротким жизненным циклом процесса это обычно означает один экземпляр в рамках текущего запроса. В долгоживущих процессах, например при использовании Laravel Octane или обработчиков очередей, такой объект может жить намного дольше. Поэтому состояние внутри singleton нужно хранить очень осторожно.
7.4. instance(). Использовать готовый объект.
$client = new HttpClient(['timeout' => 5]);
$this->app->instance(HttpClient::class, $client);
Здесь мы не даём рецепт, а сразу передаем готовый объект.
7.5. scoped(). Один экземпляр на жизненный цикл запроса.
$this->app->scoped(RequestContext::class, function () {
return new RequestContext();
});
Ведёт себя как singleton, но экземпляр привязан к текущему жизненному циклу. В долгоживущих процессах Laravel очищает такие экземпляры между отдельными запросами или заданиями. Это особенно важно для Laravel Octane и очередей: состояние одного запроса или задания не должно переходить к следующему.
7.6. singletonIf() и bindIf()
Регистрируют привязку, только если такой ещё нет. Полезно в пакетах: не перетираем то, что пользователь настроил у себя.
8. Контекстные привязки: одному одно, другому другое.
Бывает, что один и тот же интерфейс должен разрешаться по-разному в зависимости от того, кто просит. Классический пример — хранилище файлов:
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('public');
});
$this->app->when(DocumentController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('private');
});
Тем же способом можно подставлять скалярные значения, которые нельзя разрешить автоматически:
$this->app->when(SmsSender::class)
->needs('$apiKey')
->give(fn () => config('services.sms.key'));
Обратите внимание на знак доллара: здесь мы указываем не тип, а конкретное имя параметра конструктора.
Если правило одинаковое для нескольких классов, when() принимает массив:
$this->app->when([VideoController::class, UploadController::class])
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
А для самого частого случая — подстановки значения из конфига — есть сокращение:
$this->app->when(SmsSender::class)
->needs('$apiKey')
->giveConfig('services.sms.key');
Аналогично giveTagged('reports') отдаёт группу объектов, помеченных меткой (о метках — в разделе 11).
8.1. Контекстные атрибуты: то же самое, но без провайдера
Начиная с Laravel 11 у контекстных привязок появилась альтернатива, которая обычно читается лучше. Вместо того чтобы описывать правило в провайдере, вы помечаете сам параметр конструктора атрибутом:
use Illuminate\Container\Attributes\Config;
class SmsSender
{
public function __construct(
#[Config('services.sms.key')] private string $apiKey,
#[Config('services.sms.endpoint')] private string $endpoint,
) {}
}
Всё, привязка больше не нужна: контейнер увидит атрибут и подставит значение сам. Тот же приём для дисков:
use Illuminate\Container\Attributes\Storage;
use Illuminate\Contracts\Filesystem\Filesystem;
class PhotoController extends Controller
{
public function __construct(
#[Storage('public')] private Filesystem $disk,
) {}
}
Кроме Config и Storage в коробке есть Auth, Cache, Context, DB, Give, Log, RequestAttribute, RouteParameter и Tag. Отдельно стоит CurrentUser — он внедряет текущего аутентифицированного пользователя, в том числе прямо в замыкание маршрута:
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
Route::get('/user', function (#[CurrentUser] User $user) {
return $user;
})->middleware('auth');
Свой атрибут делается реализацией контракта Illuminate\Contracts\Container\ContextualAttribute: контейнер вызовет метод resolve() и подставит то, что тот вернёт.
use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
#[Attribute(Attribute::TARGET_PARAMETER)]
class CurrentTenant implements ContextualAttribute
{
public static function resolve(self $attribute, Container $container): Tenant
{
return $container->make(TenantResolver::class)->current();
}
}
Что выбрать. Атрибут удобнее, когда правило принадлежит самому классу и не меняется от окружения: ключ из конфига, конкретный диск, текущий пользователь. Привязка в провайдере лучше, когда правило именно внешнее — например, вы подменяете реализацию в зависимости от конфигурации или подсовываете свои значения в класс из чужого пакета, куда атрибут не поставить. Ни один способ не отменяет другой.
9. Как достать объект из контейнера
Способов несколько, и они не равнозначны по стилю.
// Предпочтительный способ: просто объявите тип, Laravel подставит сам
public function __construct(private OrderService $orders) {}
// Явное создание
$orders = app(OrderService::class);
$orders = App::make(OrderService::class);
$orders = resolve(OrderService::class);
// С передачей части аргументов вручную
$sender = app()->makeWith(SmsSender::class, ['apiKey' => 'test-key']);
app() и resolve() — вспомогательные функции для получения объекта из контейнера. Непосредственно у самого контейнера для этого используется метод make().
Про makeWith важно знать две вещи. Во-первых, переданные значения доходят только до сборки класса или до замыкания-рецепта (тот самый второй аргумент из раздела 6.1) и сопоставляются по имени параметра. Во-вторых, результат такого вызова не кэшируется, даже если привязка зарегистрирована как singleton: контейнер считает разрешение с параметрами частным случаем и общий экземпляр не сохраняет и не подменяет. Это ровно то поведение, которое нужно, но оно удивляет, если ждать обратного.
Контейнер также реализует стандарт PSR-11 (Psr\Container\ContainerInterface), то есть у него есть методы get() и has(). Пригодится, если библиотека умеет работать с любым PSR-совместимым контейнером. Разница с make() в поведении при ошибке: get() бросает исключения из стандарта PSR, а не BindingResolutionException.
Контейнер умеет разрешать зависимости не только в конструкторах, но и в методах:
public function store(Request $request, OrderService $orders, string $id)
{
// $request и $orders придут из контейнера,
// $id — из параметров маршрута
}
Laravel умеет внедрять зависимости в методы, которые фреймворк вызывает через контейнер. Например, зависимости передаются в методы контроллеров, а также в методы вроде handle() у заданий очереди и слушателей событий — то есть там, где объект запускает сам фреймворк. Параметры маршрута при этом сопоставляются по имени, а зависимости — по типу, поэтому порядок аргументов в сигнатуре роли не играет.
Тот же механизм доступен вручную через call():
app()->call([$report, 'generate']);
app()->call(function (OrderService $orders) {
// ...
});
10. Где живут настройки: сервис-провайдеры
Сервис-провайдер — это класс, через который Laravel настраивает часть приложения. В нём чаще всего встречаются два метода: register() и boot(). Они нужны для разных задач.
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Только привязки. Больше ничего.
$this->app->bind(NotificationSender::class, SmsSender::class);
}
public function boot(): void
{
// Здесь уже можно пользоваться другими службами
View::share('appVersion', config('app.version'));
}
}
Правило, которое стоит запомнить: в register() мы только записываем рецепты, но не готовим по ним. Причина простая: Laravel сначала вызывает register() у всех сервис-провайдеров и только потом boot() у всех. Если в register() вы попробуете достать из контейнера что-то, что регистрируется сервис-провайдером ниже по списку, вы получите ошибку или, что хуже, объект, собранный не до конца.
В boot() основные привязки уже зарегистрированы, поэтому там удобно разрешать зависимости, регистрировать обработчики событий, настраивать представления и выполнять другую инициализацию.
10.1 Откуда берутся провайдеры
После этого раздела легко решить, что провайдер — это что-то, что нужно немедленно завести. Обычно нет. Провайдеры в приложении появляются из трёх источников, и только один из них ваш.
Провайдеры самого фреймворка. Маршрутизация, кэш, очереди, сессии — всё это регистрируется провайдерами Laravel. Они загружаются автоматически, и трогать их не нужно.
Провайдеры пакетов. Начиная с Laravel 5.5 работает автообнаружение: пакет объявляет свой провайдер в composer.json (секция extra.laravel.providers), и после composer require он подключается сам. Прописывать его куда-либо вручную не требуется.
Ваши собственные. В свежем проекте уже есть один — App\Providers\AppServiceProvider. Для подавляющего большинства приложений его достаточно: все привязки живут в его register(), вся начальная настройка — в boot(). Отдельный провайдер имеет смысл заводить, когда вокруг одной подсистемы набирается заметный кусок настройки (платежи, интеграция с внешним API) и его хочется вынести, либо когда провайдер нужен отложенным, либо когда вы пишете пакет.
Если такой момент настал, файл не создают руками:
php artisan make:provider PaymentServiceProvider
В Laravel 11 и новее команда сама допишет класс в bootstrap/providers.php. В более старых версиях его нужно добавить в массив providers файла config/app.php.
10.2 Отложенные сервис-провайдеры
Если сервис-провайдер нужен редко, его можно не грузить на каждый запрос:
lass SmsServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(SmsSender::class, fn () => new SmsSender(/* ... */));
}
public function provides(): array
{
return [SmsSender::class];
}
}
```Laravel запомнит, что этот провайдер предоставляет SmsSender, и сможет загрузить провайдер только тогда, когда такая служба действительно понадобится. Соответствие «служба → провайдер» кэшируется в bootstrap/cache/services.php, поэтому после изменения provides() кэш стоит сбросить (php artisan clear-compiled).
11. Фасады и вспомогательные функции — это тоже контейнер
Многих смущает конструкция вида Cache::get('key'). Выглядит как статический вызов, но get() там точно не статический метод.
Внутри всё устроено предельно просто. Фасад — это класс, у которого определён магический метод __callStatic(). Он достаёт из контейнера настоящий объект по строковому ключу и перенаправляет вызов ему:
class Cache extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'cache';
}
}
То есть Cache::get('key') по смыслу можно представить примерно как обращение к объекту, который контейнер хранит под ключом cache: app('cache')->get('key'). Фасад скрывает получение этого объекта за статическим синтаксисом.
Отсюда же растут ноги у удобного тестирования: Cache::shouldReceive(...) просто подменяет объект в контейнере на заглушку, а весь остальной код продолжает работать, ничего не заметив.
12. Приёмы, до которых доходят не сразу
12.1 Метки (tags)
Когда нужно получить целую группу объектов:
$this->app->bind(PdfReport::class, fn () => new PdfReport());
$this->app->bind(ExcelReport::class, fn () => new ExcelReport());
$this->app->tag([PdfReport::class, ExcelReport::class], 'reports');
// Позже
foreach ($this->app->tagged('reports') as $report) {
$report->generate();
}
Отличный способ собрать все обработчики, все правила проверки или все каналы уведомлений без ручного перечисления.
Одна ловушка: tagged() возвращает не массив, а Illuminate\Container\RewindableGenerator. В foreach он ведёт себя как обычная коллекция и объекты создаются лениво, но обратиться по индексу или прогнать через array_map() не получится. Если нужен именно массив — iterator_to_array($this->app->tagged('reports')).
12.2 Расширение уже созданного (extend)
Позволяет вклиниться и обернуть объект, не переписывая исходную привязку:
$this->app->extend(NotificationSender::class, function ($sender, $app) {
return new LoggingSenderDecorator($sender, $app->make(LoggerInterface::class));
});
Особенно ценно, когда нужно доработать поведение стороннего пакета.
12.3 События контейнера
$this->app->resolving(SmsSender::class, function ($sender, $app) {
// выполнится при фактической сборке SmsSender
});
$this->app->afterResolving(...);
Вещь довольно узкая, но иногда спасает: например, для донастройки объектов из чужих пакетов.
Тонкость, которая обычно всплывает уже на отладке: коллбэк срабатывает при сборке объекта, а не при каждом обращении к контейнеру. Если служба зарегистрирована через singleton или instance, то со второго раза make() вернёт готовый экземпляр из кэша, не дойдя до коллбэков. Для bind объект собирается каждый раз, поэтому там коллбэк отработает при каждом разрешении.
13. Тестирование: ради этого тоже всё затевалось
Вернёмся к самой первой проблеме. Как теперь протестировать OrderService, не тратя деньги на реальные SMS?
Способ первый, без всякого Laravel:
$fakeSender = new class implements NotificationSender {
public array $sent = [];
public function send(string $phone, string $text): void
{
$this->sent[] = [$phone, $text];
}
};
$service = new OrderService($fakeSender);
$service->confirm($order);
$this->assertCount(1, $fakeSender->sent);
Обратите внимание: контейнер здесь вообще не нужен. Если класс принимает зависимости через конструктор, его всегда можно собрать руками. Это признак хорошего дизайна.
Способ второй, через подмену в контейнере:
$this->mock(NotificationSender::class, function ($mock) {
$mock->shouldReceive('send')->once();
});
$this->post('/orders/1/confirm');
Теперь весь код приложения, который просит NotificationSender, получит заглушку. Ни строчки в самом приложении менять не пришлось.
Есть ещё $this->swap(Интерфейс::class, $объект) и $this->instance(...) — те же идеи в разных обёртках.
14. Грабли, на которые наступают все
Синглтон с состоянием. Если в объекте, зарегистрированном как singleton, вы накапливаете данные, при обычной работе это относительно безопасно (процесс живёт один запрос). Но под Octane или в долгоживущем обработчике очереди процесс обслуживает тысячи запросов подряд, и данные одного пользователя утекут к другому. Для состояния, привязанного к запросу, используйте scoped, а не singleton.
Внедрение запроса в синглтон. Классическая ловушка: объект-синглтон принимает Illuminate\Http\Request в конструкторе. Экземпляр создаётся один раз, вместе с ним один раз разрешается и запрос — дальше объект будет вечно держать самый первый. В обычном FPM это всплывает редко, а под Octane превращается в чужие данные в ответе.
Лечится одним из трёх способов, по возрастанию сложности:
// 1. Не хранить запрос вообще: получать его в момент вызова
public function handle(): void
{
$ip = request()->ip();
}
// 2. Принимать запрос параметром метода, а не конструктора
public function handle(Request $request): void
{
$ip = $request->ip();
}
Третий способ нужен, когда объект обязан хранить запрос у себя — например, это адаптер к стороннему SDK. Тогда попросите контейнер обновлять ссылку при каждой пересборке request:
$this->app->singleton(TrackingClient::class, function ($app) {
$client = new TrackingClient($app['request']);
// при каждой пересборке 'request' объекту подсунут актуальный запрос
$app->refresh('request', $client, 'setRequest');
return $client;
});
refresh() — это обёртка над rebinding(): как только в контейнере переопределяется request, у объекта вызовется setRequest() с новым экземпляром. Ровно так внутри устроены сами компоненты Laravel, которые вынуждены держать запрос.
То же рассуждение касается всего, что живёт ровно один запрос: аутентифицированного пользователя, текущей локали, идентификатора трассировки.
Контейнер как справочная служба. Когда app(SomeService::class) разбросан по всему коду, вы формально используете контейнер, но реально вернулись к жёсткой связанности: класс снова сам добывает зависимости, просто через посредника. Зависимости при этом не видны в сигнатуре, а тестировать становится тяжело. Правило: объявляйте зависимости в конструкторе, к контейнеру обращайтесь напрямую только там, куда внедрение не дотягивается (фабрики, ленивое создание тяжёлых объектов, устаревший код).
Интерфейс ради интерфейса. Если у абстракции всегда будет ровно одна реализация и подменять её вы не собираетесь даже в тестах, интерфейс добавляет файл и не добавляет пользы. Абстракции стоят денег, они должны окупаться.
Циклическая зависимость. A требует B, B требует A — контейнер уйдёт в бесконечную рекурсию. Отдельной проверки на цикл в нём нет, поэтому осмысленного исключения вы не увидите: процесс упрётся в лимит памяти, в максимальную вложенность вызовов (если стоит Xdebug) или просто рухнет. Полезный приём при отладке — посмотреть на стек вызовов: повторяющийся фрагмент из Container::build() сразу покажет пару виновников. Это не баг контейнера, а сигнал, что ответственность между классами распределена неудачно.
15. Пишем свой контейнер
Лучший способ понять механизм — реализовать его. Ниже рабочий мини-контейнер: он умеет привязки, синглтоны и автоматическое разрешение через рефлексию. Это, конечно, сильно упрощённая модель. Она показывает основную идею автоматического разрешения зависимостей, но реальный контейнер Laravel умеет гораздо больше.
<?php
use Closure;
use ReflectionClass;
use RuntimeException;
class MiniContainer
{
/** Рецепты создания: 'Интерфейс' => ['concrete' => ..., 'shared' => bool] */
protected array $bindings = [];
/** Уже созданные общие экземпляры */
protected array $instances = [];
/** Что сейчас собираем — нужно, чтобы поймать циклическую зависимость */
protected array $buildStack = [];
public function bind(string $abstract, Closure|string|null $concrete = null, bool $shared = false): void
{
$this->bindings[$abstract] = [
'concrete' => $concrete ?? $abstract,
'shared' => $shared,
];
}
public function singleton(string $abstract, Closure|string|null $concrete = null): void
{
$this->bind($abstract, $concrete, true);
}
public function instance(string $abstract, object $object): void
{
$this->instances[$abstract] = $object;
}
/** Главный метод: получить объект по имени */
public function make(string $abstract): mixed
{
// 1. Уже создавали и договаривались хранить? Отдаём то же самое.
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// 2. Защита от циклов: A требует B, B требует A.
if (isset($this->buildStack[$abstract])) {
throw new RuntimeException(
'Циклическая зависимость: ' . implode(' -> ', array_keys($this->buildStack)) . " -> {$abstract}."
);
}
$this->buildStack[$abstract] = true;
try {
$binding = $this->bindings[$abstract] ?? null;
$concrete = $binding['concrete'] ?? $abstract;
if ($concrete instanceof Closure) {
// 3. Рецепт-функция: просто выполняем её.
$object = $concrete($this);
} elseif ($concrete === $abstract) {
// 4. Рецепта нет (или он указывает сам на себя) — строим класс.
$object = $this->build($concrete);
} else {
// 5. Рецепт — другое имя. Разрешаем его целиком,
// чтобы сработали привязки, зарегистрированные уже для него.
$object = $this->make($concrete);
}
} finally {
unset($this->buildStack[$abstract]);
}
// 6. Синглтон? Запоминаем.
if ($binding !== null && $binding['shared']) {
$this->instances[$abstract] = $object;
}
return $object;
}
/** Сборка класса через рефлексию — то самое автоматическое связывание */
protected function build(string $class): object
{
if (! class_exists($class)) {
throw new RuntimeException("Не найден класс {$class}.");
}
$reflector = new ReflectionClass($class);
if (! $reflector->isInstantiable()) {
throw new RuntimeException(
"Нельзя создать {$class}: это интерфейс или абстрактный класс. Нужна привязка."
);
}
$constructor = $reflector->getConstructor();
// Конструктора нет — создаём напрямую
if ($constructor === null) {
return new $class();
}
$arguments = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
// Скаляр, отсутствие типа, объединение или пересечение типов —
// всё это наш учебный контейнер разрешить не умеет.
// Единственное, что мы можем сделать, — взять значение по умолчанию.
if ($type === null || ! $type instanceof \ReflectionNamedType || $type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw new RuntimeException(
"Не могу разрешить параметр \${$parameter->getName()} класса {$class}."
);
}
// Тип — класс или интерфейс: рекурсивно создаём его.
// Если не вышло, но параметр допускает значение по умолчанию, берём его.
try {
$arguments[] = $this->make($type->getName());
} catch (RuntimeException $e) {
if (! $parameter->isDefaultValueAvailable()) {
throw $e;
}
$arguments[] = $parameter->getDefaultValue();
}
}
return $reflector->newInstanceArgs($arguments);
}
}
Проверим:
interface NotificationSender {
public function send(string $phone, string $text): void;
}
class SmsSender implements NotificationSender {
public function __construct(private string $apiKey) {}
public function send(string $phone, string $text): void {
echo "SMS на {$phone}: {$text}\n";
}
}
class OrderService {
public function __construct(public NotificationSender $sender) {}
}
$container = new MiniContainer();
$container->bind(NotificationSender::class, function () {
return new SmsSender('секретный-ключ');
});
$service = $container->make(OrderService::class);
$service->sender->send('+7999', 'Заказ подтверждён');
Мы нигде не сказали, как создавать OrderService. Контейнер сам прочитал конструктор, увидел NotificationSender, нашёл для него рецепт, выполнил его и собрал всё вместе.
В Laravel происходит похожий процесс: контейнер анализирует зависимости, находит зарегистрированные правила и создаёт объекты. Но реальный контейнер значительно сложнее: он поддерживает контекстные привязки и атрибуты, метки, события разрешения, extend, scoped, rebinding, разные времена жизни объектов, передачу параметров и множество других случаев.
Чего наш мини-контейнер намеренно не умеет — полезно держать в голове, если вдруг захочется взять его как основу:
- variadic-параметры (
__construct(Handler ...$handlers)) — Laravel умеет собирать их из привязки-массива или из метки; - типы
self,static,parent— формально этоReflectionNamedTypeсisBuiltin() === false, иmake('self')предсказуемо упадёт; makeWith, то есть передачу части аргументов вручную;scopedи сброс таких экземпляров между запросами;extend, метки, коллбэкиresolvingи контекстные привязки.
Зато основная идея — прочитать конструктор рефлексией и рекурсивно собрать зависимости — здесь ровно та же, что и в настоящем контейнере.
16. Итог
Если оставить одну мысль, пусть будет эта:
Классы не должны сами добывать то, чем пользуются, — они должны честно объявлять это в конструкторе. Это внедрение зависимостей. А контейнер — то, что делает такой стиль посильным: он берёт на себя сборку, которая иначе расползлась бы по всему приложению.
Всё остальное — следствия:
- зависимости видны в сигнатуре конструктора, класс сам себя документирует;
- реализация меняется в одной строке сервис-провайдера, а не в тридцати местах;
- тесты пишутся без плясок, потому что подменить можно что угодно;
- сборка сложных объектов происходит один раз в одном месте.
Практический совет напоследок.
Не регистрируйте вручную каждый класс и не создавайте интерфейсы просто «на всякий случай». Автоматическое разрешение зависимостей покрывает множество обычных случаев без какой-либо настройки. Явные привязки особенно часто нужны, когда используется интерфейс, который нельзя создать напрямую, когда в конструктор нужно передать конкретные значения, или когда требуется особое управление созданием и временем жизни объекта.
Есть и другие возможности контейнера — контекстные привязки и атрибуты, метки, расширение уже созданных объектов и другие специальные случаи. Главное — использовать их тогда, когда они действительно решают задачу.