Service Container и Dependency Injection
Архитектурное ядро Laravel: как фреймворк связывает сотни сервисов между собой. Понимание контейнера отличает junior от middle и определяет, как пишется поддерживаемый код в долгоживущих проектах.
Service Container — невидимая, но центральная подсистема Laravel. Любой контроллер, любая команда Artisan, любой Job, любое Event-событие — всё проходит через контейнер. Он отвечает за создание объектов с правильными зависимостями, переиспользование экземпляров, подмену реализаций в тестах и сборку из конфигов.
Поверхностное знакомство с Laravel позволяет писать рабочий код, не задумываясь о контейнере: фреймворк автоматически подставляет зависимости в конструкторы. Однако всё, что выходит за рамки CRUD — интеграции с внешними системами, расширяемые модули, многотенантность, тестирование с моками, написание собственных пакетов — требует осознанной работы с контейнером.
- Возможность подменять реализации без переписывания кода (мок, fake, decorator);
- Понимание того, как Laravel внутренне собирает фреймворк из независимых сервис-провайдеров;
- Способность писать переиспользуемые пакеты с публичным API;
- Умение читать чужой код, опирающийся на контейнер;
- Корректные паттерны для тестов:
bind,instance,swap.
- KB_1 — PHP OOP: интерфейсы, абстрактные классы, конструкторы, наследование;
- KB_3 разделы 2 и 3 — базовое понимание Service Container и провайдеров;
- KB_9 — что такое
bootable traitи как Laravel расширяет модели.
| Блок | Что узнаешь |
|---|---|
| Концепции | Что такое DI, чем отличается контейнер от Service Locator |
| Bindings | Все способы регистрации: bind, singleton, instance, scoped, contextual, tagged |
| Разрешение | Как Laravel создаёт объекты: autowiring, рекурсия конструкторов, primitives |
| Providers | Жизненный цикл фреймворка через ServiceProvider; deferred & package providers |
| Практика | Расширяемая платёжная подсистема: 4 провайдера, 6 банков, заменяемые драйверы |
Service Container (контейнер служб, иногда называемый IoC-контейнером — Inversion of Control) — объект-фабрика, отвечающий за создание других объектов в приложении. Когда коду нужен экземпляр класса с зависимостями, он не создаёт его напрямую через new, а просит контейнер: «дай мне экземпляр UserRepository». Контейнер сам разбирает, какие зависимости нужны конструктору этого класса, рекурсивно создаёт их, и возвращает готовый объект.
Это решает несколько задач одновременно. Во-первых, инверсия зависимостей: класс не знает, как создаётся Mailer, ему достаточно объявить, что в конструкторе нужен Mailer-интерфейс. Во-вторых, единое место регистрации: вся «сборка» приложения собирается в провайдерах, а не размазывается по сотням мест с new. В-третьих, заменяемость реализаций: в тесте можно сказать «когда просят Mailer, верни fake», и весь код будет работать с этим fake без модификаций.
В Laravel контейнер реализован классом Illuminate\Container\Container; Illuminate\Foundation\Application — его подкласс, добавляющий специфичные для Laravel возможности (события приложения, информацию об окружении, маршрутизацию). Глобальный экземпляр доступен через хелпер app(), через фасад App, через инъекцию интерфейса Illuminate\Contracts\Container\Container.
Abstract и concrete
Abstract — «ключ», под которым сервис зарегистрирован: имя интерфейса, имя абстрактного класса, или произвольная строка. Concrete — конкретная реализация: имя класса или замыкание, возвращающее экземпляр.
// abstract = интерфейс, concrete = конкретный класс
app()->bind(
PaymentGateway::class, // abstract
StripeGateway::class, // concrete
);
// abstract = строковый ключ, concrete = замыкание
app()->bind('payment.fee_calculator', function (Container $app) {
return new FeeCalculator($app->make(CurrencyService::class));
});
Resolution — разрешение зависимости
При вызове app()->make($abstract) или автоматическом разрешении (например, в параметрах конструктора контроллера) контейнер выполняет цепочку действий:
- Проверяет, есть ли зарегистрированный bind для
$abstract; - Если есть — вызывает соответствующий callback или инстанцирует concrete-класс;
- Если нет, но
$abstract— имя конкретного класса — пытается инстанцировать его напрямую (autowiring); - Для каждого параметра конструктора рекурсивно разрешает зависимости;
- Для primitive-параметров (строки, числа без type-hint) ищет контекстные bindings или применяет значение по умолчанию;
- Вызывает события
resolvingиafterResolving; - Возвращает готовый экземпляр.
Singleton vs Transient
Сервис может быть зарегистрирован как singleton (один и тот же экземпляр на всё время жизни приложения) или как transient (каждое разрешение возвращает новый экземпляр). По умолчанию bind() регистрирует transient, singleton() — разделяемый.
app()->bind(Mailer::class, SmtpMailer::class);
$a = app(Mailer::class);
$b = app(Mailer::class);
// $a !== $b — разные экземпляры
app()->singleton(Mailer::class, SmtpMailer::class);
$a = app(Mailer::class);
$b = app(Mailer::class);
// $a === $b — один и тот же экземпляр
Сравним «ручную» сборку и сборку через контейнер на примере класса, шлющего уведомления.
Без контейнера: вызывающий код вынужден знать, как создать все зависимости.
class NotificationService
{
public function __construct(
private SmtpMailer $mailer,
SmsGateway $sms,
TemplateEngine $templates,
) {}
}
// Контроллер вынужден явно собирать графы зависимостей.
public function store(Request $request)
{
$mailer = new SmtpMailer(config('mail.host'), config('mail.port'));
$sms = new SmsGateway(config('sms.token'));
$templates = new TemplateEngine(resource_path('templates'));
$service = new NotificationService($mailer, $sms, $templates);
$service->send(...);
}
С контейнером: контроллер объявляет, что ему нужно, и получает готовый сервис.
public function store(Request $request, NotificationService $service)
{
$service->send(...);
// Laravel сам создал SmtpMailer, SmsGateway, TemplateEngine
// и собрал из них NotificationService.
}
Контейнер «знает», как создаются SmtpMailer, SmsGateway, TemplateEngine, потому что эти зависимости были зарегистрированы в сервис-провайдерах. Контроллер не несёт ответственности за их сборку.
app() внутри классов — антипаттерн (Service Locator). Вызов app(SomeService::class) внутри метода класса делает класс зависимым от контейнера. Зависимости становятся неявными: глядя на класс, невозможно понять, что ему нужно для работы. Корректный подход — объявлять зависимости в конструкторе.
A зависит от B, а B зависит от A, контейнер не может разрешить ни один из них — выпадет BindingResolutionException. Решение — пересмотр архитектуры (выделить общую зависимость, использовать события или ленивое разрешение).
Dependency Injection (внедрение зависимостей) — принцип проектирования, при котором класс получает свои зависимости извне, а не создаёт их самостоятельно. Класс становится независимым от способа создания зависимости: ему передают готовый объект через конструктор, метод или свойство.
DI — принцип, не привязанный к фреймворку и языку. Контейнер — реализация этого принципа: инструмент, автоматизирующий передачу зависимостей. Можно применять DI без контейнера (ручная передача в конструктор), но в проекте с сотнями классов это становится непрактичным; контейнер автоматизирует процесс.
Главная ценность DI — разрыв жёсткой связи между классами через интерфейсы. Класс OrderService зависит от интерфейса PaymentGateway, а не от конкретного StripeGateway. Конкретная реализация выбирается в одном месте (провайдере), и любая её замена не требует правки кода, использующего сервис.
Constructor Injection — основной способ
Зависимости объявляются в параметрах конструктора с типами. Это самый частый, самый явный и предпочтительный способ. При создании объекта через контейнер все параметры конструктора автоматически разрешаются.
class OrderService
{
public function __construct(
private readonly PaymentGateway $payment,
private readonly InventoryService $inventory,
private readonly Mailer $mailer,
) {}
public function place(array $items): Order
{
// Зависимости доступны как свойства объекта.
$this->inventory->reserve($items);
$result = $this->payment->charge(...);
$this->mailer->send(...);
}
}
Конструктор делает зависимости обязательными: класс невозможно создать без них, что устраняет «полузаполненные» состояния. Использование readonly и private дополнительно гарантирует неизменяемость и инкапсуляцию.
Method Injection
Зависимость объявляется в параметрах конкретного метода, а не конструктора. Применяется, когда зависимость нужна только в одной операции класса, либо когда метод обрабатывает действие пользователя (контроллер, slash-команда, обработчик webhook).
class UserController
{
// Метод-обработчик контроллера. Laravel инжектирует все типизированные параметры.
public function store(
StoreUserRequest $request, // FormRequest со встроенной валидацией
UserService $users, // сервис из контейнера
EventBus $events, // ещё одна зависимость
): JsonResponse {
$user = $users->create($request->validated());
$events->dispatch(new UserRegistered($user));
return response()->json($user);
}
}
Method injection поддерживается во всех «точках входа» Laravel: контроллеры, обработчики команд Artisan, jobs (handle), event listeners, middleware (handle). Если необходимо инжектировать зависимость в произвольный метод произвольного класса, используется app()->call($object, 'method').
Setter Injection
Зависимость передаётся через сеттер-метод после создания объекта. В Laravel применяется редко и обычно через свойства, помеченные специально (например, Container::resolving). Подход даёт возможность опциональных зависимостей, но усложняет проверку, что объект полностью настроен.
class CacheableRepository
{
private ?CacheStore $cache = null;
public function setCache(CacheStore $cache): self
{
$this->cache = $cache;
return $this;
}
}
// Через resolving callback можно автоматически вызывать setter.
app()->resolving(CacheableRepository::class, function (CacheableRepository $repo, Container $app) {
$repo->setCache($app->make(CacheStore::class));
});
Interface Injection
Зависимости объявляются на уровне интерфейса. Класс реализует контракт SetCacheAware, и контейнер автоматически вызывает соответствующий метод после создания. В Laravel это не используется как идиома, но именно так работают внутренние механизмы вроде Illuminate\Contracts\Container\BindingResolutionException.
DI (Dependency Injection) — это конкретный паттерн: способ предоставления объекту его зависимостей извне вместо создания их внутри через new.
DIP (Dependency Inversion Principle) — это принцип из SOLID: модули должны зависеть от абстракций (интерфейсов), а не от конкретных классов.
new.
Три стадии на PHP (Laravel-стиль):
// Нарушены оба принципа: создание зависимости внутри
class OrderService
{
public function process()
{
$gateway = new PayPalGateway(); // жёсткая привязка
$gateway->charge();
}
}
// DI есть, но DIP нарушен: внедряем конкретный класс
class OrderService
{
public function __construct(private PayPalGateway $gateway) {}
// Тесты уже возможны (можно подсунуть mock),
// но контроллер знает про конкретный PayPal.
// Смена на Stripe = правка type-hint во всех местах.
}
// DI + DIP соблюдены: внедряем абстракцию
interface PaymentGatewayInterface
{
public function charge(): ChargeResult;
}
class PayPalGateway implements PaymentGatewayInterface { /* ... */ }
class StripeGateway implements PaymentGatewayInterface { /* ... */ }
class OrderService
{
public function __construct(private PaymentGatewayInterface $gateway) {}
// Класс не знает про PayPal / Stripe.
// Замена реализации — 1 строчка в провайдере.
}
// В провайдере — выбор реализации в одном месте:
$this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);
| new внутри | DI + конкретный класс | DI + интерфейс | |
|---|---|---|---|
| DI | Нет | Да | Да |
| DIP | Нет | Нет | Да |
| Тестируемость | Требует боевого API | Можно подсунуть mock через контейнер | Любая реализация интерфейса |
| Смена провайдера | Править везде где new | Править type-hint во всех местах | Одна строка в провайдере |
В Laravel контейнер помогает автоматически разрешать зависимости, но выбор абстракций (интерфейсов) остаётся за разработчиком — именно это и есть следование DIP. Контейнер не заставит вас использовать интерфейс — он лишь предоставляет удобный механизм биндинга, который вы можете использовать правильно (интерфейс → реализация) или неправильно (конкретный класс → сам себя).
Рассмотрим эволюцию класса от тесной связи с конкретной реализацией к гибкому DI через интерфейс. Видимое улучшение происходит в трёх измерениях: тестируемость, заменяемость, читаемость.
Этап 1: прямая зависимость от конкретного класса. Не тестируется без работающего внешнего API; смена провайдера требует переписывания.
class OrderController
{
public function checkout(Request $request): Response
{
// Класс жёстко зависит от StripeGateway.
$gateway = new StripeGateway(config('services.stripe.key'));
$result = $gateway->charge($request->amount);
// В тесте — реальные обращения к Stripe API.
}
}
Этап 2: внедрение через конструктор. Зависимость явная, тесты могут подсунуть mock.
class OrderController
{
public function __construct(private readonly StripeGateway $gateway) {}
public function checkout(Request $request): Response
{
$result = $this->gateway->charge($request->amount);
}
}
// В тесте
$mock = Mockery::mock(StripeGateway::class);
$mock->shouldReceive('charge')->andReturn(new ChargeResult(true));
app()->instance(StripeGateway::class, $mock);
Этап 3: зависимость от интерфейса. Контроллер вообще не знает, какой провайдер используется. Можно менять Stripe на PayPal через провайдер, без изменений в контроллере.
interface PaymentGateway
{
public function charge(int $amountCents, string $currency): ChargeResult;
}
class StripeGateway implements PaymentGateway { /* ... */ }
class PaypalGateway implements PaymentGateway { /* ... */ }
// В провайдере
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// или, в зависимости от конфигурации:
$this->app->bind(PaymentGateway::class, function () {
return match (config('payment.driver')) {
'stripe' => app(StripeGateway::class),
'paypal' => app(PaypalGateway::class),
};
});
class OrderController
{
public function __construct(private readonly PaymentGateway $gateway) {}
// Контроллер не знает, какой провайдер активен.
}
__construct(StripeGateway $gateway) требует именно StripeGateway. Зависимость от интерфейса (PaymentGateway) делает класс гибким к замене реализации. Если интерфейс пока одинокий — всё равно стоит его ввести: при добавлении второго провайдера не придётся править все вызовы.
$service->process($data, Mailer $mailer)) не получит автоматического разрешения параметров. Используйте конструктор или явный вызов app(Mailer::class).
app() внутри метода вместо constructor injection. Это Service Locator-антипаттерн. Класс становится зависимым от глобального состояния контейнера. Симптомы — невозможность создать класс без полностью загруженного фреймворка, скрытые зависимости, сложные тесты. Корректно — всегда объявлять зависимости в конструкторе.
Регистрация связи между ключом и конкретной реализацией — основное действие, которое выполняется в сервис-провайдерах. От выбора метода регистрации зависит, как часто будет создаваться экземпляр, разделяется ли он между потребителями и каким образом конкретный класс получит свои зависимости.
Laravel предоставляет три базовых метода: bind() для transient-сервисов (каждое разрешение — новый экземпляр), singleton() для разделяемых сервисов (один экземпляр на весь запрос), instance() для регистрации уже созданного объекта. Выбор зависит от природы сервиса: stateless-сервис без побочных эффектов выгоднее singleton, stateful или дорогой в создании — transient.
Быстрая сводка перед детальным разбором:
| Метод | Что делает | Одной фразой |
|---|---|---|
bind($abstract, $concrete) | Регистрирует привязку (transient) | каждый вызов — новый экземпляр |
singleton($abstract, $concrete) | Регистрирует привязку-singleton | первый вызов создаёт, дальше — тот же экземпляр |
make($abstract) | Создаёт экземпляр (разрешает зависимости рекурсивно) | «дай мне готовый объект по этому имени» |
call($callback) | Вызывает метод/функцию, автоматически внедряя зависимости в аргументы | «вызови и подставь всё что нужно» |
bind vs singleton — принципиально:
bind— контейнер хранит только алгоритм создания (замыкание/класс). Каждыйmake()запускает алгоритм заново.singleton— контейнер хранит и алгоритм, и готовый объект после первого разрешения. Второйmake()вернёт кэшированный.
singleton. Сервис с состоянием, которое не должно разделяться (билдер запроса, DTO-фабрика) → bind.
Классическая история — почему bind нужен и как всё связывается вместе. Пять файлов, каждый один-два экрана.
// 1. Интерфейс — контракт (app/Contracts/PaymentGateway.php)
interface PaymentGateway
{
public function charge(User $user, float $amount): void;
}
// 2. Реализация (app/Services/StripePayment.php)
class StripePayment implements PaymentGateway
{
public function charge(User $user, float $amount): void {
// вызов Stripe API
}
}
// 3. Привязка в AppServiceProvider::register()
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripePayment::class);
}
// 4. Action зависит от ИНТЕРФЕЙСА, а не от конкретной реализации
class PlaceOrder
{
public function __construct(private PaymentGateway $payment) {}
public function handle(User $user, array $items): Order { ... }
}
// 5. Контроллер — Laravel сам собирает PlaceOrder со всеми зависимостями
public function store(StoreOrderRequest $request, PlaceOrder $action)
{
// $action уже готов, внутри — StripePayment (или что задано в bind)
$order = $action->handle($request->user(), $request->validated()['items']);
}
Что получили:
- Смена шлюза = 1 строка. Завтра переходим на YooKassa — реализуем
YooKassaPayment implements PaymentGateway, меняемbind(..., YooKassaPayment::class). Action не трогаем. - Тесты без Stripe API. В тесте
app()->bind(PaymentGateway::class, FakePayment::class)— Action получит fake. - Никаких
new. Action не знает, кто создаёт зависимости. Контейнер собирает всё сам через autowiring.
Контейнер Illuminate\Container\Container хранит три ключевых массива:
| Свойство | Что содержит |
|---|---|
$bindings | Массив [abstract => ['concrete' => ..., 'shared' => bool]] — алгоритм создания + флаг singleton |
$instances | Уже созданные singleton-объекты (кэш) |
$resolved | Флаги — какие абстрактные имена уже разрешались хоть раз |
$contextual | Контекстные binding'и: «для класса A — этот, для B — другой» |
$aliases | Псевдонимы: 'logger' => Logger::class |
Упрощённая реализация bind():
public function bind($abstract, $concrete = null, $shared = false)
{
$this->bindings[$abstract] = compact('concrete', 'shared');
}
// singleton — это просто bind с $shared = true
public function singleton($abstract, $concrete = null)
{
$this->bind($abstract, $concrete, true);
}
Схема make():
- Есть в
$instances(уже разрешённый singleton)? → отдать его. - Есть в
$contextualдля текущего родителя? → взять оттуда. - Есть в
$bindings? → выполнитьconcrete(замыкание или new-класс). - Ничего нет? → рефлексия: посмотреть конструктор класса, рекурсивно разрешить каждый параметр, вызвать
new. - Если
shared === true— сохранить результат в$instances.
bind кладёт рецепт в $bindings. singleton — тот же bind + флаг «сохрани результат». make — «найди рецепт, свари». call — «свари всё что нужно для аргументов этой функции и вызови её».
bind() (а не autowiring)Laravel умеет создавать любой конкретный класс без явной регистрации — через autowiring по type hints. bind() нужен когда:
1. Интерфейс → реализация
Код зависит от PaymentGateway, но контейнер не знает какую конкретно реализацию подставить. bind(PaymentGateway::class, Stripe::class) — говорим явно.
2. Конструктор с примитивами
Класс требует string $apiKey или int $timeout — контейнер не может вывести их автоматически. Регистрируем через замыкание: bind(X::class, fn ($app) => new X(config('services.x.key'))).
3. Сложная фабричная логика
Создание требует чтения конфига, HTTP-запроса, инициализации соединения. Оборачиваем в замыкание.
4. Псевдонимы / короткие имена
bind('logger', Logger::class) — теперь app('logger') работает как сокращение полного имени класса.
5. Разные реализации в разных окружениях
В AppServiceProvider::register(): if ($this->app->environment('testing')) $this->app->bind(Mailer::class, FakeMailer::class).
bind($abstract, $concrete = null, $shared = false)
Регистрирует transient-связь. Каждый вызов app()->make($abstract) создаёт новый экземпляр. Применяется для сервисов с внутренним состоянием, которое не должно делиться между потребителями, либо когда стоимость создания низка.
// Простейший вариант — интерфейс на класс.
app()->bind(Mailer::class, SmtpMailer::class);
// С замыканием — когда требуется кастомная логика создания.
app()->bind(Mailer::class, function (Container $app) {
$config = $app->make(Repository::class)->get('mail');
return new SmtpMailer($config['host'], $config['port']);
});
// Если abstract совпадает с concrete, второй параметр опускается.
app()->bind(UserService::class);
// Эквивалентно явному bind(UserService::class, UserService::class).
Метод bindIf() регистрирует связь только если для указанного abstract ещё нет регистрации. Применяется в библиотечном коде, чтобы не перезаписывать пользовательскую конфигурацию.
singleton($abstract, $concrete = null)
Регистрирует singleton-связь. При первом make() экземпляр создаётся; все последующие вызовы возвращают тот же объект до окончания запроса.
// Кэширующий сервис с внутренним состоянием — singleton по природе.
app()->singleton(RedisStore::class, function (Container $app) {
return new RedisStore(
host: config('redis.host'),
port: config('redis.port'),
prefix: config('cache.prefix'),
);
});
$store1 = app(RedisStore::class);
$store2 = app(RedisStore::class);
// $store1 === $store2 — одно подключение на запрос.
Метод singletonIf() — идемпотентный аналог. bindMethod() позволяет переопределить способ вызова метода (используется при $container->call()).
instance($abstract, $instance)
Регистрирует уже существующий экземпляр. В отличие от singleton, объект создаётся вне контейнера и просто передаётся в него. Главное применение — тесты: подменить реальный сервис на mock, fake или stub.
// В тесте
public function test_order_uses_mocked_payment(): void
{
$mock = new class implements PaymentGateway {
public function charge(int $amount, string $currency): ChargeResult
{
return new ChargeResult(success: true, id: 'fake-123');
}
};
app()->instance(PaymentGateway::class, $mock);
// Любой код, запрашивающий PaymentGateway из контейнера,
// получит этот fake вместо реального Stripe.
$response = $this->postJson('/orders', [...]);
$response->assertStatus(201);
}
Сводная таблица
| Метод | Когда создаётся | Сколько экземпляров | Применение |
|---|---|---|---|
bind | При каждом make | Новый каждый раз | Stateful сервисы, fluent-объекты, lightweight классы |
singleton | При первом make | Один на весь запрос | Stateless-сервисы, подключения к БД/Redis, конфигурация |
instance | Снаружи контейнера | Один указанный | Тесты, runtime-замена сервиса, ранее созданный объект |
bindIf / singletonIf | Идемпотентно | Зависит от вызова | Библиотечный код, default-регистрация без перезаписи |
Что выбирать: singleton или bind
Правило: singleton, если сервис stateless (внутреннее состояние не накапливается, методы не оставляют побочных эффектов в свойствах). В этом случае повторное создание экземпляра было бы пустой тратой ресурсов.
bind (transient), если сервис содержит состояние, которое не должно делиться между потребителями. Примеры: построитель запросов (каждый запрос — новый builder с собственными условиями), value object с изменяемыми полями, временный кэш в рамках одной операции.
В подавляющем большинстве случаев в Laravel-приложении сервисы — stateless, поэтому singleton используется чаще, чем bind.
Рассмотрим регистрацию типового набора сервисов магазина: репозитории (transient), кэш-менеджер (singleton), runtime-конфигурация (instance). Все регистрации выполняются в AppServiceProvider или специализированных провайдерах.
namespace App\Providers;
use App\Cache\StoreCacheManager;
use App\Contracts\Catalog\ProductRepository;
use App\Contracts\Pricing\PriceCalculator;
use App\Repositories\EloquentProductRepository;
use App\Services\Pricing\DefaultPriceCalculator;
use App\Services\Pricing\StoreFeatures;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
class CatalogServiceProvider extends ServiceProvider
{
public function register(): void
{
// 1. Репозиторий — transient. Каждый запрос строит свой query builder.
$this->app->bind(ProductRepository::class, EloquentProductRepository::class);
// 2. Кэш-менеджер — singleton. Дорогое соединение к Redis
// переиспользуется в течение запроса.
$this->app->singleton(StoreCacheManager::class, function (Container $app) {
return new StoreCacheManager(
redis: $app->make('redis.connection'),
prefix: config('cache.prefix'),
defaultTtl: 3600,
);
});
// 3. Калькулятор цен — singleton, потому что использует кэш и не имеет состояния.
$this->app->singleton(PriceCalculator::class, DefaultPriceCalculator::class);
// 4. Runtime-конфигурация фич — instance, потому что объект собирается
// из конфига при boot приложения.
$features = new StoreFeatures(config('store.features', []));
$this->app->instance(StoreFeatures::class, $features);
}
}
Использование в коде:
class ProductController
{
// Все четыре зависимости автоматически разрешатся контейнером.
public function __construct(
private readonly ProductRepository $repository,
private readonly StoreCacheManager $cache,
private readonly PriceCalculator $pricing,
private readonly StoreFeatures $features,
) {}
public function show(int $id): View
{
$product = $this->cache->remember("product.{$id}",
fn () => $this->repository->findOrFail($id));
return view('product.show', [
'product' => $product,
'final_price' => $this->pricing->calculate($product),
'show_promo' => $this->features->enabled('promo-banner'),
]);
}
}
flush на событиях RequestReceived/RequestTerminated.
singleton() вызывается ровно один раз — при первом make. Если в замыкании используется состояние, доступное только при определённых условиях (текущий пользователь, тенант), и при первом разрешении это состояние не было установлено, singleton навсегда зафиксирует «дефолтное» значение. Для контекстно-зависимых сервисов используйте transient или scoped.
instance не вызывает resolving-колбэки. Если на сервис зарегистрированы app()->resolving($abstract, fn() => ...), для объекта, переданного через instance(), они не выполнятся — объект уже создан вне контейнера. Это нужно учитывать при подмене сервисов в тестах.
bind с дорогими операциями в замыкании. Если замыкание открывает соединение или читает большой файл, transient-bind будет повторять это при каждом make. Скорее всего такому сервису место в singleton.
new A(new B(new C(...))). Лучше извлекать зависимости через $app->make(...), чтобы каждая участвующая зависимость собиралась контейнером:
app()->singleton(OrderService::class, function (Container $app) {
return new OrderService(
$app->make(PaymentGateway::class),
$app->make(InventoryService::class),
);
});
Scoped binding — промежуточный по поведению режим между bind и singleton. Сервис создаётся один раз в рамках определённого «жизненного цикла» и сбрасывается при его завершении. В Laravel scoped введён ради совместимости с Octane и подобными окружениями, где приложение живёт между запросами и singleton может «протечь» из одного запроса в другой.
В классическом PHP-FPM (запрос — отдельный процесс) разницы между singleton и scoped нет: оба переживают весь запрос и уничтожаются вместе с процессом. В Octane разница принципиальна: singleton сохраняется на всё время жизни worker-процесса (минуты, часы), scoped — только на длительность одного HTTP-запроса. Контейнер автоматически вызывает forgetScopedInstances() между запросами.
Тот же механизм применяется в long-running сценариях вне HTTP: очереди (job — «область»), Artisan-команды с большим обходом данных, тесты с RefreshDatabase. Везде, где сервис должен быть «свежим» для каждой логической операции, но при этом разделяться между шагами этой операции, применяется scoped.
scoped($abstract, $concrete = null)
Регистрирует сервис, который создаётся при первом разрешении внутри текущей «области» (scope) и переиспользуется до сброса области. По умолчанию область совпадает с запросом в Octane / queue-job / тестом.
app()->scoped(RequestContext::class, function (Container $app) {
return new RequestContext(
userId: auth()->id(),
tenantId: app(TenantManager::class)->currentId(),
traceId: (string) Str::uuid(),
);
});
// В рамках одного запроса все обращения возвращают тот же объект.
$ctx1 = app(RequestContext::class);
$ctx2 = app(RequestContext::class);
// $ctx1 === $ctx2
// При начале следующего запроса контейнер вызовет forgetScopedInstances()
// и при следующем make создаст новый RequestContext с актуальными значениями.
scopedIf($abstract, $concrete = null)
Идемпотентный вариант: регистрирует scoped-binding только если для abstract ещё нет регистрации. Используется в пакетах и провайдерах, чтобы не перезаписать пользовательскую настройку.
forgetScopedInstances()
Сбрасывает все scoped-инстансы. В Octane вызывается автоматически между запросами; в собственном long-running коде (Artisan-команды, обработчики очередей) можно вызывать вручную при переходе к новой логической области.
public function handle(): void
{
foreach (Tenant::all() as $tenant) {
app(TenantManager::class)->setCurrent($tenant);
// Каждая итерация — новая область:
// сбрасываем scoped, чтобы RequestContext построился под нового тенанта.
app()->forgetScopedInstances();
$this->processForTenant();
}
}
Сравнение singleton, scoped, bind
| Аспект | bind | scoped | singleton |
|---|---|---|---|
| Время жизни | Один make | До forgetScopedInstances | Всё время приложения |
| Поведение в PHP-FPM | Новый каждый make | Один на запрос | Один на запрос (то же что scoped) |
| Поведение в Octane | Новый каждый make | Один на HTTP-запрос | Один на worker-процесс (опасно для state) |
| Использование | Stateful, fluent, lightweight | Контекст запроса, transaction-scope | Stateless connections, конфиги, регистры |
Рассмотрим SaaS-приложение в Octane, где для каждого HTTP-запроса нужен свежий объект TenantContext с идентификатором арендатора, текущим пользователем и trace-id для логирования. В обычных условиях этот объект использовался бы как singleton, но в Octane между запросами разных тенантов состояние утекало бы. scoped решает проблему.
namespace App\Tenancy;
final class TenantContext
{
public function __construct(
public readonly int $tenantId,
public readonly ?int $userId,
public readonly string $traceId,
public readonly Carbon $startedAt,
) {}
}
// app/Providers/TenancyServiceProvider.php
use Illuminate\Support\ServiceProvider;
class TenancyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->scoped(TenantContext::class, function (Container $app) {
$resolver = $app->make(TenantResolver::class);
return new TenantContext(
tenantId: $resolver->currentTenantId() ?? throw new RuntimeException('Tenant not resolved'),
userId: auth()->id(),
traceId: (string) Str::uuid(),
startedAt: now(),
);
});
}
}
// Использование в коде
class AuditLogger
{
public function __construct(private readonly TenantContext $context) {}
public function record(string $action, array $payload = []): void
{
AuditEntry::create([
'tenant_id' => $this->context->tenantId,
'user_id' => $this->context->userId,
'trace_id' => $this->context->traceId,
'action' => $action,
'payload' => $payload,
]);
}
}
Поведение в трёх окружениях:
- PHP-FPM: процесс создаётся под запрос и уничтожается после ответа. Singleton и scoped работают одинаково.
- Octane: worker-процесс живёт долго. На каждый HTTP-запрос Octane вызывает
app()->forgetScopedInstances();TenantContextпересоздаётся с актуальным тенантом. Singleton сохранил бы первый созданный контекст и применял его ко всем последующим тенантам. - Queue worker: длительный процесс, обрабатывающий jobs последовательно. Laravel сбрасывает scoped перед обработкой каждого job, поэтому job получает свой контекст.
scoped ведёт себя идентично singleton. Различие проявится только при переходе на Octane или подобные long-running окружения. Регистрировать сервисы как scoped имеет смысл превентивно — чтобы будущий переход не сломал поведение.
forgetScopedInstances между итерациями будет хранить тот же scoped-инстанс на всём её выполнении. Если контекст должен меняться (например, обход тенантов), необходимо явно сбрасывать область.
app() внутри метода или передача через аргумент.
RefreshDatabase. Однако scoped-инстансы контейнера не сбрасываются автоматически. При необходимости вызовите app()->forgetScopedInstances() в setUp() тестового класса.
Contextual binding (контекстное связывание) позволяет регистрировать разные реализации одного интерфейса для разных потребителей. Если двум классам нужен один и тот же интерфейс Filesystem, но первому — локальный диск, а второму — S3, контекстный binding это решает декларативно, без нагромождения условий внутри классов.
Без контекстного binding пришлось бы либо передавать конкретную реализацию вручную в каждый класс (нарушая DI), либо помещать логику выбора реализации внутрь самих классов через config('app.driver') или Service Locator. Контекстный binding выносит решение «какая реализация для какого класса» в провайдер, где оно концентрируется в одном месте.
Метод сложнее bind/singleton по синтаксису, но даёт мощную гибкость для архитектурных задач: разделение драйверов между разными частями приложения, разные конфигурации для контекстов «писатель/читатель», подмена реализаций в специальных контроллерах (например, admin-панели с расширенными правами).
when()->needs()->give() — основной паттерн
Цепочка читается как фраза: «когда контейнер создаёт $class и тот нуждается в $abstract, дай ему $concrete».
app()->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('s3-photos');
});
app()->when(VideoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('s3-videos');
});
Оба контроллера в конструкторе объявляют Filesystem $files, но получают разные диски — в зависимости от того, какой контроллер создаёт контейнер.
Несколько потребителей — массив
Если одна и та же контекстная связка нужна для нескольких классов, первым аргументом when() передаётся массив имён.
app()->when([PhotoUploadJob::class, PhotoCleanupCommand::class])
->needs(Filesystem::class)
->give(fn () => Storage::disk('photos'));
Контекстный binding по имени параметра
Иногда требуется привязка не по типу зависимости, а по имени параметра конструктора — для скаляров и строк, которые не имеют типа-интерфейса.
class StripeGateway
{
public function __construct(
private string $apiKey,
private string $webhookSecret,
) {}
}
app()->when(StripeGateway::class)
->needs('$apiKey')
->give(config('services.stripe.key'));
app()->when(StripeGateway::class)
->needs('$webhookSecret')
->give(config('services.stripe.webhook_secret'));
Префикс $ в имени отделяет «primitive»-зависимости от типизированных. Альтернатива — передавать значения через замыкание-фабрику в обычном bind, но контекстный binding по имени параметра делает интенцию явной.
giveTagged() — передача набора tagged-сервисов
Если потребитель ожидает iterable набор однотипных сервисов (например, валидаторы, фильтры, обработчики), и эти сервисы зарегистрированы под общим тегом, в контекстном binding можно передать всю группу одной строкой.
// Сначала регистрируем валидаторы под общим тегом:
app()->bind(EmailValidator::class);
app()->bind(PhoneValidator::class);
app()->tag([EmailValidator::class, PhoneValidator::class], 'contact-validators');
// Теперь передаём всю группу в нужный класс:
app()->when(RegistrationService::class)
->needs(Validator::class)
->giveTagged('contact-validators');
Подробнее о тегах — в подразделе «Tagged services».
giveConfig() — значение из конфигурации
Сокращённый способ передать значение из config() в primitive-параметр без оборачивания в замыкание.
app()->when(PaymentService::class)
->needs('$apiKey')
->giveConfig('services.payment.key');
В медиа-приложении одно и то же приложение работает с несколькими хранилищами: фото — в S3 bucket media-photos, видео — в media-videos (часто другой регион из-за объёмов), временные файлы — в локальной директории, экспортированные отчёты — в защищённом S3 bucket. Контроллеры и сервисы каждой подсистемы объявляют в конструкторе Filesystem, но получают разные реализации.
namespace App\Providers;
use App\Console\Commands\GenerateReports;
use App\Http\Controllers\PhotoController;
use App\Http\Controllers\VideoController;
use App\Jobs\ProcessImportFile;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
class StorageBindingsProvider extends ServiceProvider
{
public function register(): void
{
// Фото-модуль: чтение и запись через PhotoController, PhotoUploadJob.
$this->app->when([PhotoController::class, PhotoUploadJob::class])
->needs(Filesystem::class)
->give(fn () => Storage::disk('media-photos'));
// Видео-модуль: отдельный bucket в другом регионе.
$this->app->when(VideoController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('media-videos'));
// Импорт-задачи работают с локальной временной директорией.
$this->app->when(ProcessImportFile::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('imports'));
// Отчёты: защищённое хранилище с приватными ссылками.
$this->app->when(GenerateReports::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('reports-private'));
// Default для всех остальных потребителей — публичный диск.
$this->app->bind(Filesystem::class, fn () => Storage::disk('public'));
}
}
Каждый из контроллеров и job'ов в коде выглядит одинаково — они не знают о том, какой диск получают:
class PhotoController
{
public function __construct(private readonly Filesystem $files) {}
public function store(UploadedFile $file): JsonResponse
{
$path = $this->files->putFile('uploads', $file);
// $this->files — это диск 'media-photos', хотя контроллер этого не знает.
}
}
class VideoController
{
public function __construct(private readonly Filesystem $files) {}
// Тот же тип зависимости, но получит другой диск.
}
Достигнутые преимущества:
- Контроллеры остаются простыми и тестируемыми — в тесте подменяется только bind на in-memory диск.
- Изменение хранилища (например, перенос фото с S3 на CloudFront) затрагивает один провайдер, не контроллеры.
- Можно динамически менять биндинг для конкретного контекста в feature-flag сценариях.
new PhotoController(...), контейнер не участвует, и контекстный binding не применяется. В Laravel это обычно не проблема (контроллеры всегда инстанцируются через контейнер), но при ручном создании в тестах учитывать обязательно.
when(A)->needs(B)->give(C) срабатывает только когда B запрашивается непосредственно для A. Если B запрашивает класс D, а D не упомянут в контекстном binding, D получит «дефолтную» реализацию. Контекст не распространяется глубоко по графу зависимостей.
when()->needs()->give() для каждого приводит к разрастанию провайдера. Решение — перечислить классы массивом в одном when(), либо рассмотреть рефакторинг (выделить общий интерфейс, использовать tagged).
app()->instance($abstract, $mock), контекстный binding не отключается автоматически: если в тесте подмена не учитывает контекст, мог получиться mock не от того контекста. Для полной подмены либо переопределите контекстный binding в тесте, либо используйте app()->when($class)->needs($abstract)->give($mock).
Tagged services — механизм группировки нескольких сервисов под общим логическим тегом для последующего получения всей группы одним вызовом. Применяется, когда приложение содержит несколько однотипных компонентов, образующих коллекцию: набор валидаторов, цепочка обработчиков команд (chain of responsibility), плагины, генераторы отчётов, экспортёры различных форматов.
Без тегов получение всех реализаций интерфейса требовало бы поддержки явного списка в коде или конфиге. Например, для обработки команды чата нужно знать все доступные команды; для генерации Excel-отчёта — все экспортёры. Тегирование позволяет регистрировать новые компоненты декларативно: добавил класс, отметил тегом, и существующая логика автоматически его подхватит.
Тег — обычная строка-идентификатор. Контейнер хранит список abstracts, помеченных конкретным тегом. При запросе tagged($tag) возвращается ленивый итератор, разрешающий каждый abstract в полноценный сервис.
tag($abstracts, $tags) — добавление тегов
Принимает массив abstracts (имена классов/интерфейсов) и тег или массив тегов. Можно вызывать несколько раз — накопление аддитивное.
// Регистрация сервисов
app()->bind(EmailValidator::class);
app()->bind(PhoneValidator::class);
app()->bind(AddressValidator::class);
// Тегирование группой
app()->tag(
[EmailValidator::class, PhoneValidator::class, AddressValidator::class],
'contact-validators'
);
// Можно повесить несколько тегов сразу
app()->tag([XmlExporter::class], ['exporters', 'serializers']);
tagged($tag) — получение группы
Возвращает RewindableGenerator — ленивый итератор, разрешающий каждый из помеченных сервисов при обращении. Можно итерировать в foreach, превратить в массив через iterator_to_array.
class ContactValidationService
{
public function validateAll(array $data): array
{
$errors = [];
foreach (app()->tagged('contact-validators') as $validator) {
$errors = array_merge($errors, $validator->validate($data));
}
return $errors;
}
}
Tagged через инъекцию в конструктор
Более идиоматично — получить tagged-набор как зависимость через контекстный binding с giveTagged(). Это даёт тот же результат, но без обращения к контейнеру внутри метода (избегая Service Locator-антипаттерна).
class ContactValidationService
{
public function __construct(
private readonly iterable $validators,
) {}
public function validateAll(array $data): array
{
$errors = [];
foreach ($this->validators as $validator) {
$errors = array_merge($errors, $validator->validate($data));
}
return $errors;
}
}
// В провайдере:
app()->when(ContactValidationService::class)
->needs('$validators')
->giveTagged('contact-validators');
Приложение должно уметь экспортировать отчёт в несколько форматов: CSV, XLSX, JSON, PDF. Каждый формат реализован отдельным классом. Список форматов меняется со временем: добавляются новые, удаляются устаревшие. Главное требование — чтобы добавление нового формата не требовало правок в существующем коде, выбирающем формат.
namespace App\Reporting\Exporters;
interface ReportExporter
{
public function format(): string;
public function label(): string;
public function export(Report $report): StreamedResponse;
}
class CsvExporter implements ReportExporter
{
public function format(): string { return 'csv'; }
public function label(): string { return 'CSV (Excel)'; }
public function export(Report $report): StreamedResponse { /* ... */ }
}
class XlsxExporter implements ReportExporter
{
public function __construct(private readonly SpreadsheetEngine $engine) {}
public function format(): string { return 'xlsx'; }
public function label(): string { return 'Excel 2016+'; }
public function export(Report $report): StreamedResponse { /* ... */ }
}
// Аналогично JsonExporter, PdfExporter.
Регистрация и тегирование в провайдере:
class ReportingServiceProvider extends ServiceProvider
{
public function register(): void
{
// Регистрируем каждый экспортёр как singleton (stateless по природе).
$this->app->singleton(CsvExporter::class);
$this->app->singleton(XlsxExporter::class);
$this->app->singleton(JsonExporter::class);
$this->app->singleton(PdfExporter::class);
// Помечаем общим тегом для последующей итерации.
$this->app->tag(
[
CsvExporter::class,
XlsxExporter::class,
JsonExporter::class,
PdfExporter::class,
],
'report-exporters'
);
// Менеджер экспорта получает всю группу через giveTagged.
$this->app->when(ReportExportManager::class)
->needs('$exporters')
->giveTagged('report-exporters');
}
}
Менеджер экспорта, использующий tagged-группу:
class ReportExportManager
{
// Индексированный по формату массив экспортёров.
private readonly array $byFormat;
public function __construct(iterable $exporters)
{
$this->byFormat = collect($exporters)
->keyBy(fn (ReportExporter $e) => $e->format())
->all();
}
public function available(): array
{
return collect($this->byFormat)
->map(fn (ReportExporter $e) => $e->label())
->all();
// → ['csv' => 'CSV (Excel)', 'xlsx' => 'Excel 2016+', 'json' => 'JSON', 'pdf' => 'PDF']
}
public function export(Report $report, string $format): StreamedResponse
{
if (! isset($this->byFormat[$format])) {
throw new UnsupportedFormatException($format);
}
return $this->byFormat[$format]->export($report);
}
}
Использование в контроллере:
class ReportController
{
public function __construct(private readonly ReportExportManager $exporter) {}
public function download(Report $report, Request $request): StreamedResponse
{
return $this->exporter->export($report, $request->input('format', 'csv'));
}
}
Добавление нового формата (например, XML) сводится к: написать класс XmlExporter implements ReportExporter, зарегистрировать его в провайдере, дописать в массив tag(). Менеджер, контроллер, шаблон выбора формата — ничего из этого менять не нужно.
tagged() возвращает ленивый итератор. Метод возвращает RewindableGenerator, а не массив. Однократный обход в foreach работает корректно; повторный — тоже (объект rewindable). Однако функции вроде count() на нём не работают — нужно либо iterator_count(), либо iterator_to_array().
tag() только записывает связь «abstract принадлежит тегу». Если сам abstract не зарегистрирован через bind/singleton и не является автоматически разрешаемым классом, при итерации tagged() разрешение провалится с BindingResolutionException. Тегирование не заменяет регистрации.
tag(). При программной регистрации (например, через discovery в подкаталоге) порядок зависит от файловой системы. Если порядок важен (chain of responsibility, priority), реализуйте его явно — либо через сортировку после получения, либо через специальный метод priority() на каждом сервисе.
iterable vs массив в типе зависимости. Параметр конструктора лучше объявлять как iterable, чтобы оставить контейнеру свободу передачи генератора (быстрее) или массива (после iterator_to_array). Жёсткий тип array заставит контейнер материализовать набор немедленно, что может быть дороже.
Resolution — процесс, в котором контейнер по запрошенному abstract создаёт готовый объект со всеми зависимостями. Autowiring — автоматическое разрешение зависимостей без явной регистрации, на основе анализа типов в сигнатуре конструктора. Эти два механизма работают вместе и определяют, насколько «легко» в коде получить сервис.
Понимание процесса разрешения важно, когда необходимо вызвать произвольный метод с инъекцией зависимостей (app()->call()), подмешать значения в существующий объект (resolving-колбэки), отладить почему контейнер выбрасывает BindingResolutionException. Большую часть времени разработчик не задумывается о деталях разрешения — всё работает «магически», но понимание превращает магию в инженерную предсказуемость.
Алгоритм разрешения детерминирован и пошаговый: проверка кеша инстансов, поиск registered binding, попытка autowiring через рефлексию, рекурсия по параметрам конструктора, применение контекстных biding, primitive resolution через дефолтные значения. Каждый шаг имеет осмысленную точку расширения для опытного пользователя.
make($abstract, $parameters = []) — основной метод
Возвращает разрешённый экземпляр. Второй параметр — массив значений для primitive-зависимостей конструктора, перекрывающий контейнерное разрешение.
// Получить полностью разрешённый сервис.
$service = app()->make(OrderService::class);
// То же самое сокращённой формой через хелпер.
$service = app(OrderService::class);
// Передать значения для primitive-параметров.
$gateway = app()->make(StripeGateway::class, [
'apiKey' => 'sk_test_...',
'webhookSecret' => 'whsec_...',
]);
// Эти значения перекрывают любые контейнерные binding для $apiKey и $webhookSecret.
resolve($abstract, $parameters) — синоним make
Функционально эквивалентен make(). В некоторых местах исходного кода Laravel используется именно resolve — для семантической ясности. В пользовательском коде обычно применяется make или хелпер app().
app()->call($callback, $parameters) — вызов с инъекцией
Вызывает произвольное callable (замыкание, метод объекта, имя класса в формате «Class@method»), автоматически разрешая параметры через контейнер.
// Замыкание с инъекцией зависимостей.
app()->call(function (UserRepository $users, Mailer $mailer) {
// $users и $mailer разрешены автоматически.
});
// Метод объекта.
$service = new ReportService();
app()->call([$service, 'generate'], ['year' => 2024]);
// Параметры с типами разрешаются контейнером,
// primitive (year) — берутся из второго аргумента.
// Класс@метод — Laravel создаст инстанс и вызовет метод.
app()->call('App\Services\ReportService@generate');
resolving() и afterResolving() — колбэки
Хуки, вызываемые после создания объекта определённого типа. Позволяют дополнительно настраивать сервис после конструктора — например, регистрировать listeners, подмешивать конфигурацию.
// Вызывается каждый раз перед возвратом разрешённого сервиса.
app()->resolving(Logger::class, function (Logger $logger, Container $app) {
$logger->pushProcessor(fn (array $record) => array_merge($record, [
'trace_id' => $app->make(RequestContext::class)->traceId,
]));
});
// Вызывается после возврата сервиса, для пост-обработки.
app()->afterResolving(PaymentGateway::class, function (PaymentGateway $gateway) {
logger()->info('PaymentGateway resolved');
});
// Без указания abstract — на все разрешения подряд.
app()->resolving(function ($object, Container $app) {
// Логирование любого разрешения, полезно для аудита.
});
Алгоритм разрешения зависимости
При вызове make($abstract) контейнер выполняет следующие шаги:
- Проверка кеша scoped/singleton-инстансов. Если для
$abstractуже хранится экземпляр, и binding регистрировался как singleton или scoped — возвращается этот экземпляр немедленно. - Применение контекстного binding. Если сейчас контейнер разрешает класс A, и для него зарегистрирован
when(A)->needs($abstract)->give(...), используется этот binding. - Поиск зарегистрированного binding. Если есть
bind/singletonдля$abstract, вызывается соответствующий callback или инстанцируется указанный concrete-класс. - Autowiring. Если binding не найден, но
$abstract— имя реального класса, контейнер пытается создать его напрямую через рефлексию конструктора. - Рекурсивное разрешение зависимостей конструктора. Для каждого параметра конструктора:
- Если тип параметра — класс/интерфейс — рекурсивно
makeпо нему; - Если primitive — ищется значение в
$parameters(явно переданном) или в контекстном binding по имени параметра; - Если нет ни того, ни другого — используется default-значение параметра;
- Если default нет —
BindingResolutionException.
- Если тип параметра — класс/интерфейс — рекурсивно
- Сохранение в кеш singleton/scoped. Если binding регистрировался как singleton, экземпляр сохраняется для последующих запросов.
- Вызов
resolving-колбэков. Все зарегистрированные на этот abstract callbacks вызываются с готовым объектом. - Возврат экземпляра. Объект отдаётся вызывающему коду.
Autowiring — разрешение без регистрации
Если запрошенный abstract — имя конкретного класса (не интерфейс, не абстрактный класс), и binding для него не зарегистрирован, контейнер использует рефлексию для автоматической сборки.
// Класс не зарегистрирован в провайдерах, но autowiring сработает.
class EmailNotifier
{
public function __construct(
private Mailer $mailer,
private TemplateEngine $templates,
) {}
}
$notifier = app(EmailNotifier::class);
// Контейнер разрешит Mailer и TemplateEngine рекурсивно,
// и инстанцирует EmailNotifier с ними.
Autowiring не работает для интерфейсов и абстрактных классов — их нельзя инстанцировать, поэтому требуется явное связывание с конкретной реализацией через bind или singleton.
Рассмотрим, как контейнер разрешает сложный граф зависимостей, и как влияют разные виды биндингов.
interface PaymentGateway { public function charge(int $cents): ChargeResult; }
class StripeGateway implements PaymentGateway
{
public function __construct(
private readonly HttpClient $http, // типизированная — autowire
private readonly string $apiKey, // primitive — контекстный bind
private readonly int $timeoutSec = 30, // primitive с default
) {}
}
class CheckoutService
{
public function __construct(
private readonly PaymentGateway $gateway,
private readonly OrderRepository $orders,
private readonly EventDispatcher $events,
) {}
}
// Регистрация в провайдере:
app()->bind(PaymentGateway::class, StripeGateway::class);
app()->when(StripeGateway::class)
->needs('$apiKey')
->giveConfig('services.stripe.key');
app()->singleton(HttpClient::class);
Что происходит при app(CheckoutService::class):
- Контейнер: запрошен
CheckoutService. Binding не найден, но это конкретный класс — autowire через рефлексию. - Параметр
$gateway: PaymentGateway. Это интерфейс. Найден bindingPaymentGateway → StripeGateway. Рекурсивныйmake(StripeGateway::class). - Контейнер разрешает
StripeGateway:$http: HttpClient— найден singleton-binding, инстанцируется (или возвращается из кеша). Autowire его конструктора.$apiKey: string— primitive. Найден контекстный binding сgiveConfig('services.stripe.key'). Получаем значение из конфига.$timeoutSec: int = 30— primitive без binding, используется default 30.
- Возврат к
CheckoutService. Параметр$orders: OrderRepository— autowire (без явного binding). - Параметр
$events: EventDispatcher— разрешён из встроенных биндингов фреймворка (Laravel регистрирует его вEventServiceProvider). - Конструктор
CheckoutServiceвызывается со всеми тремя зависимостями. Объект готов.
Эту цепочку можно отладить, зарегистрировав глобальный resolving-колбэк:
app()->resolving(function ($object, Container $app) {
logger()->debug('Resolved: ' . $object::class);
});
app(CheckoutService::class);
// В логе: Resolved: HttpClient, StripeGateway, OrderRepository, EventDispatcher, CheckoutService
BindingResolutionException для интерфейса. Самая частая ошибка: контроллер ожидает PaymentGateway в конструкторе, но binding не зарегистрирован. Контейнер не может разрешить интерфейс без явного указания concrete. Решение — зарегистрировать bind(PaymentGateway::class, StripeGateway::class) в провайдере.
BindingResolutionException. Решение — bind на конкретную реализацию.
string или int без default-значения и без контекстного when()->needs('$param')->give(...) не разрешится. Это design-флаг: либо параметр должен быть зависимостью (тип-интерфейс), либо иметь default, либо контекст.
app() внутри метода (с осознанной потерей чистоты DI).
resolving() и singleton. Колбэки resolving вызываются каждый раз когда возвращается объект, включая случаи когда возвращается уже закешированный singleton. Это означает, что колбэк может выполняться много раз для одного и того же объекта. Учитывайте идемпотентность.
int $userId, контейнер сам найдёт». Нет, контейнер не угадывает значения scalar-параметров. Их нужно передавать через app()->make($class, ['userId' => 42]) или через контекстный binding по имени.
Контейнер публикует события на ключевых моментах своей работы: разрешение зависимости (resolving, afterResolving) и перепривязка ранее зарегистрированного binding (rebinding). Эти точки расширения позволяют выполнить дополнительные действия над созданным объектом или отреагировать на смену реализации в runtime.
События контейнера — не то же самое, что Application events Laravel (UserCreated, OrderPlaced). Контейнерные события низкого уровня, относятся к самому процессу создания сервисов и регистрируются методами на контейнере, а не через систему Event/Listener.
Главные практические применения: централизованная настройка одного типа сервисов (например, добавить trace-id во все логгеры, навесить middleware на все HTTP-клиенты), отслеживание перерегистрации binding для каскадного обновления зависимых сервисов, аудит создаваемых объектов в dev-окружении.
resolving($abstract, $callback)
Колбэк вызывается каждый раз, когда контейнер возвращает экземпляр указанного abstract. Срабатывает как для свежесозданных, так и для уже закешированных singleton'ов. Первый аргумент колбэка — разрешённый объект, второй — экземпляр контейнера.
// Колбэк для конкретного abstract.
app()->resolving(Logger::class, function (Logger $logger, Container $app): void {
$context = $app->make(RequestContext::class);
$logger->withContext(['trace_id' => $context->traceId]);
});
// Колбэк на все типы — для аудита или дебага.
app()->resolving(function ($object, Container $app): void {
logger()->debug('Container resolved: ' . $object::class);
});
afterResolving($abstract, $callback)
Аналогичен resolving, но вызывается после того, как все resolving-колбэки отработали и объект готов к возврату. Полезен для финального шага настройки, когда нужно гарантировать, что предыдущие хуки уже отработали.
app()->resolving(HttpClient::class, function (HttpClient $client) {
$client->middleware(new RetryMiddleware(3));
});
app()->afterResolving(HttpClient::class, function (HttpClient $client) {
// Здесь RetryMiddleware гарантированно уже навешан.
$client->middleware(new LoggingMiddleware());
});
rebinding($abstract, $callback)
Колбэк вызывается при повторной регистрации abstract, который уже был зарегистрирован ранее. Используется в редких случаях, когда зависимость сервиса нужно обновить runtime после смены реализации.
class UserService
{
private Logger $logger;
public function __construct(Container $app)
{
$this->logger = $app->make(Logger::class);
// Если кто-то перепривяжет Logger позже, обновим ссылку.
$app->rebinding(Logger::class, function (Container $app, Logger $logger) {
$this->logger = $logger;
});
}
}
// Позже в коде:
app()->bind(Logger::class, FileLogger::class);
// → колбэк UserService сработает, $logger обновится на FileLogger.
Метод refresh($abstract, $target, $method) — сахар над rebinding: автоматически вызывает указанный сеттер при перебиндинге.
extend($abstract, $callback)
«Декорирует» существующий binding: колбэк получает уже разрешённый объект и должен вернуть его (возможно, обёрнутый или модифицированный). Часто применяется для добавления функциональности к чужим сервисам.
app()->extend(PaymentGateway::class, function (PaymentGateway $gateway, Container $app) {
// Возвращаем декорированный gateway с логированием и retry.
return new LoggingPaymentGateway(
new RetryablePaymentGateway($gateway, 3),
$app->make(Logger::class),
);
});
// Все последующие make(PaymentGateway::class) вернут декорированную версию.
Сводная таблица событий и хуков
| Метод | Когда вызывается | Применение |
|---|---|---|
resolving | При каждом разрешении abstract, перед возвратом | Настройка свойств, регистрация listeners |
afterResolving | После всех resolving-колбэков | Финальный шаг после всех других хуков |
rebinding | При повторной регистрации binding | Обновление ссылок на сервис в существующих объектах |
refresh | То же что rebinding + автоматический вызов сеттера | Удобная форма rebinding для типового кейса |
extend | Декорирование binding | Обёртывание сервиса в декоратор |
Приложение использует HTTP-клиент для интеграций с тремя внешними API (Stripe, Twilio, Slack). Каждый клиент должен иметь общие настройки: retry с экспоненциальным бэкоффом, логирование запросов, trace-id в заголовках, метрики времени ответа. Без событий контейнера эту настройку пришлось бы дублировать в каждой регистрации; через resolving — в одном месте.
namespace App\Providers;
use App\Http\Middleware\Client\LoggingMiddleware;
use App\Http\Middleware\Client\MetricsMiddleware;
use App\Http\Middleware\Client\RetryMiddleware;
use App\Http\Middleware\Client\TraceMiddleware;
use GuzzleHttp\Client as HttpClient;
use Illuminate\Support\ServiceProvider;
class HttpClientServiceProvider extends ServiceProvider
{
public function register(): void
{
// Базовые конструкторы клиентов — без middleware.
$this->app->singleton('http.stripe', fn () => new HttpClient(['base_uri' => 'https://api.stripe.com']));
$this->app->singleton('http.twilio', fn () => new HttpClient(['base_uri' => 'https://api.twilio.com']));
$this->app->singleton('http.slack', fn () => new HttpClient(['base_uri' => 'https://slack.com']));
// Единая настройка через resolving — применится к каждому из трёх клиентов.
foreach (['http.stripe', 'http.twilio', 'http.slack'] as $key) {
$this->app->resolving($key, function (HttpClient $client, Container $app) {
$stack = $client->getConfig('handler');
$stack->push(new RetryMiddleware(maxAttempts: 3, backoffMs: 200));
$stack->push(new TraceMiddleware($app->make(RequestContext::class)));
$stack->push(new LoggingMiddleware($app->make(Logger::class)));
$stack->push(new MetricsMiddleware($app->make(MetricsCollector::class)));
});
}
}
}
Преимущества подхода: один источник истины для настройки HTTP-клиентов. Добавление нового middleware (например, circuit breaker) — одна строка в провайдере, применится ко всем клиентам автоматически. Добавление нового HTTP-клиента — одна регистрация плюс ключ в массиве, и все middleware подхватятся.
Альтернатива через extend — ещё более лаконичная, но создаёт цепочку декораторов вместо модификации существующего объекта:
foreach (['http.stripe', 'http.twilio', 'http.slack'] as $key) {
$this->app->extend($key, function (HttpClient $client, Container $app) {
return new DecoratedHttpClient($client, [
new RetryDecorator(3),
new TraceDecorator($app->make(RequestContext::class)),
new LoggingDecorator($app->make(Logger::class)),
]);
});
}
resolving вызывается на каждом make, не только при первом. Для singleton, который был создан и закеширован, колбэк всё равно сработает при повторных app(Singleton::class). Это часто упускают: если в колбэке выполняется тяжёлая работа (запрос к API), она будет повторяться. Применяйте идемпотентную или однократно-применяемую логику.
resolving на интерфейс vs concrete. Колбэк, зарегистрированный на интерфейс (resolving(PaymentGateway::class, ...)), сработает только когда контейнер разрешает запрос к этому интерфейсу. Если код напрямую запрашивает StripeGateway::class (concrete), колбэк не вызовется. Для гарантированного срабатывания регистрируйте колбэки и на интерфейс, и на конкретный класс.
rebinding для непривязанных abstracts. Метод срабатывает только при перерегистрации уже существующего binding. Если abstract разрешается через autowiring (без явного binding), rebinding-колбэк никогда не вызовется — нет события, которому он мог бы соответствовать.
extend и singleton-кеш. Метод extend применяется при разрешении: если singleton уже был создан до вызова extend, кеш содержит «недекорированную» версию. Решение — вызвать extend в провайдере до первого make, или явно очистить кеш через forgetInstance($abstract).
resolving зарегистрирован после того, как abstract уже был один раз разрешён в singleton, для существующего экземпляра колбэк не сработает (объект уже в кеше). Все колбэки должны быть зарегистрированы в register() провайдеров до начала работы приложения.
Service Provider — точка инициализации модуля приложения. Это класс, наследующий Illuminate\Support\ServiceProvider, в котором приложение или сторонний пакет сообщает фреймворку: «вот мои сервисы, маршруты, миграции, конфиг, observers». Провайдер выполняет роль композиционного корня (composition root) для своей подсистемы.
Laravel сам — набор провайдеров. Подключение к БД, событийная система, маршрутизация, авторизация, очереди, кэш — каждый из этих модулей фреймворка зарегистрирован отдельным провайдером в vendor/laravel/framework/src/Illuminate/.../<*ServiceProvider.php>. Этот же механизм доступен приложениям и пакетам.
Провайдеры решают две задачи. Первая — регистрация связок в контейнере (bind, singleton, контекстные binding) до того, как фреймворк начнёт обрабатывать запросы. Вторая — «boot»-инициализация: загрузка миграций, регистрация Blade-директив, объявление маршрутов, подписка на события — всё, что требует уже собранного контейнера.
Базовая структура
Провайдер генерируется командой php artisan make:provider. Стандартный класс наследует ServiceProvider и определяет два метода: register() и boot().
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class PaymentServiceProvider extends ServiceProvider
{
// Регистрация: только связки контейнера.
public function register(): void
{
$this->app->singleton(PaymentGateway::class, StripeGateway::class);
}
// Boot: всё остальное.
public function boot(): void
{
Route::prefix('payments')->group(base_path('routes/payments.php'));
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations/payments');
Event::listen(PaymentSucceeded::class, UpdateOrderStatus::class);
}
}
Регистрация провайдера в приложении
Создание класса провайдера — половина дела. Чтобы Laravel его подхватил, необходимо зарегистрировать. В Laravel 11+ это делается в bootstrap/providers.php; в более ранних версиях — в массиве providers файла config/app.php.
// Laravel 11+ — bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\PaymentServiceProvider::class,
App\Providers\CatalogServiceProvider::class,
];
// До Laravel 11 — config/app.php
'providers' => [
// ... системные провайдеры ...
App\Providers\PaymentServiceProvider::class,
],
Доступ к контейнеру внутри провайдера
В провайдере доступно свойство $this->app — экземпляр контейнера. Все методы регистрации (bind, singleton, when, tag) вызываются на нём.
public function register(): void
{
$this->app->singleton(Mailer::class, SmtpMailer::class);
$this->app->bind(UserRepository::class, EloquentUserRepository::class);
}
Методы провайдера для типовых задач
| Метод | Назначение |
|---|---|
$this->loadMigrationsFrom($path) | Подключить миграции из указанной директории (для пакетов, модульной структуры). |
$this->loadRoutesFrom($path) | Подключить маршруты из файла. |
$this->loadViewsFrom($path, $namespace) | Зарегистрировать неймспейс для Blade-шаблонов. |
$this->loadTranslationsFrom($path, $namespace) | Подключить переводы пакета. |
$this->mergeConfigFrom($path, $key) | Слить дефолтную конфигурацию пакета с пользовательской. |
$this->publishes($paths, $groups) | Объявить публикуемые ресурсы (config, миграции, assets) для php artisan vendor:publish. |
$this->commands($commands) | Зарегистрировать классы Artisan-команд (вызывается в boot). |
Виды провайдеров
В Laravel есть несколько разновидностей провайдеров, различающихся семантикой и моментом загрузки:
- Обычный
ServiceProvider— загружается при каждом запросе. Подходит для общих модулей. - Deferred Provider (реализует
DeferrableProvider) — откладывает загрузку до момента, когда впервые понадобится зарегистрированный им сервис. Подходит для тяжёлых пакетов, используемых редко. - Package Provider — провайдер, поставляемый в составе Composer-пакета. Регистрируется автоматически через mechanism «package auto-discovery» из
composer.jsonпакета.
Все три — одни и те же базовые классы; различие — в наличии метода provides() и в способе регистрации.
Рассмотрим организацию крупного приложения как набора независимых модулей: Catalog, Orders, Billing, Notifications. Каждый модуль — набор сервисов, моделей, маршрутов, миграций, замкнутых на своей предметной области. Каждый модуль имеет один Service Provider, отвечающий за его инициализацию.
namespace App\Modules\Catalog\Providers;
use App\Modules\Catalog\Contracts\ProductRepository;
use App\Modules\Catalog\Repositories\EloquentProductRepository;
use App\Modules\Catalog\Services\PriceCalculator;
use App\Modules\Catalog\Observers\ProductObserver;
use App\Modules\Catalog\Models\Product;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class CatalogServiceProvider extends ServiceProvider
{
public function register(): void
{
// Слияние конфига модуля с пользовательской настройкой.
$this->mergeConfigFrom(
__DIR__ . '/../config/catalog.php', 'catalog'
);
// Регистрация контрактов и реализаций.
$this->app->bind(ProductRepository::class, EloquentProductRepository::class);
$this->app->singleton(PriceCalculator::class);
}
public function boot(): void
{
// Маршруты модуля.
Route::middleware('web')
->prefix('catalog')
->group(__DIR__ . '/../routes/web.php');
// Миграции и шаблоны.
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'catalog');
// Observer для модели.
Product::observe(ProductObserver::class);
// Publishable: конфиг можно скопировать в config/ командой vendor:publish.
$this->publishes([
__DIR__ . '/../config/catalog.php' => config_path('catalog.php'),
], 'catalog-config');
}
}
Структура файлов модуля:
Подключение модуля сводится к одной строке в bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
App\Modules\Catalog\Providers\CatalogServiceProvider::class,
App\Modules\Orders\Providers\OrdersServiceProvider::class,
App\Modules\Billing\Providers\BillingServiceProvider::class,
App\Modules\Notifications\Providers\NotificationsServiceProvider::class,
];
Преимущества такой структуры:
- Каждый модуль автономен: его можно отключить (закомментировав провайдер) или перенести в отдельный пакет;
- Зависимости между модулями явные — через интерфейсы и контейнер, без прямых импортов классов другого модуля;
- Тестирование модуля отдельно от остальной системы упрощается;
- В крупных командах разные модули можно развивать параллельно без конфликтов в namespace.
bind/singleton в boot() или, наоборот, использование других сервисов в register(). В register() доступен только контейнер и binding-методы; остальные сервисы могут быть ещё не зарегистрированы. В boot() контейнер уже полностью собран. Подробнее — в подразделе «register vs boot».
boot использует сервис из провайдера B, который ещё не загрузился — BindingResolutionException. Решение — перенести регистрацию в register (выполняется раньше у всех провайдеров) или использовать события booted.
register. Провайдер вызывается на каждый запрос. Длительные операции (чтение файлов, обращения к сети, инициализация ресурсов) в register замедляют каждый запрос приложения. Откладывайте такую работу либо в boot (если только при первой надобности), либо в Deferred Provider.
providers.php. Класс создан, метод register написан, но ничего не работает — первая проверка: добавлен ли провайдер в массив. Без регистрации Laravel о нём не знает.
$defer, $bindings, $singletons); пользовательские данные хранятся в сервисах, регистрируемых через контейнер.
register. Метод Product::observe() требует, чтобы класс модели был уже загружен и Eloquent был инициализирован. В register() это не гарантируется. Регистрация observer'ов должна выполняться в boot().
Жизненный цикл загрузки приложения Laravel состоит из двух фаз: фаза регистрации и фаза boot. Каждый ServiceProvider может участвовать в обеих фазах, реализуя методы register() и boot(). Понимание разделения между ними критично: размещение кода в неправильной фазе приводит к ошибкам, неочевидным для начинающих, но прозрачным после знакомства с алгоритмом.
Принцип разделения: в фазе регистрации нельзя предполагать, что другие сервисы уже доступны — они могут быть зарегистрированы более поздними провайдерами. В фазе boot контейнер полностью собран — можно использовать любой сервис, регистрировать обработчики событий, маршруты, наблюдателей моделей.
Метод booted() — третья опция, выполняющаяся после всех boot() всех провайдеров. Используется для финальных инициализаций, требующих полной готовности приложения.
Фаза 1: register
Laravel поочерёдно создаёт все провайдеры и вызывает у каждого метод register(). В этой фазе:
- Доступно только свойство
$this->app(контейнер) и методы binding (bind,singleton,when,tag); - Запрещено разрешать другие сервисы через
app()->make()— они могут быть ещё не зарегистрированы; - Запрещены вызовы фасадов (
Route,Event,Storageи др.), потому что их базовые сервисы зарегистрированы в системных провайдерах, порядок которых не гарантирован; - Можно регистрировать множество биндингов любых сервисов — они будут «отложены», их создание произойдёт позже.
public function register(): void
{
// Допустимо: регистрация связки.
$this->app->singleton(PaymentGateway::class, StripeGateway::class);
// Допустимо: контекстный binding.
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('photos'));
// Здесь Storage::disk() в замыкании — выполнится при разрешении PhotoController,
// а не сейчас. Это безопасно.
// Опасно: попытка использовать другие сервисы прямо сейчас.
// $config = $this->app->make('config'); ← может быть не зарегистрирован.
// Route::get(...); ← фасад работает с сервисом router, которого может не быть.
}
Фаза 2: boot
После регистрации всех провайдеров Laravel снова обходит их по очереди и вызывает boot(). В этой фазе:
- Контейнер полностью собран — любой сервис доступен через
app()->make()или фасады; - Можно регистрировать маршруты, observers моделей, Blade-директивы, event listeners;
- Можно вызывать
$this->loadMigrationsFrom(),$this->loadViewsFrom(),$this->publishes(); - Метод
bootподдерживает method injection: параметры можно типизировать, контейнер их разрешит автоматически.
public function boot(EventBus $events, CacheManager $cache): void
{
// Все сервисы доступны.
Route::prefix('api')->group(base_path('routes/api.php'));
Product::observe(ProductObserver::class);
Blade::directive('money', fn ($expr) => "<?php echo number_format($expr, 2); ?>");
// Параметры boot() разрешаются через method injection.
$events->listen(OrderPlaced::class, SendOrderConfirmation::class);
// Можно делать переменную, влияющую на runtime-поведение.
if ($this->app->isProduction()) {
Model::preventLazyLoading(false);
} else {
Model::preventLazyLoading();
}
}
Фаза 3: booted
После того как у всех провайдеров отработал boot(), Laravel вызывает booted-колбэки. Это удобный способ выполнить код, требующий полностью загруженного приложения.
public function boot(): void
{
// Регистрируем колбэк, который выполнится после всех boot.
$this->app->booted(function (Application $app) {
// На этот момент все провайдеры уже отработали boot.
// Все маршруты, observers, listeners зарегистрированы.
$schedule = $app->make(Schedule::class);
$schedule->command('app:custom-cleanup')->daily();
});
}
Свойства-сокращения: $bindings, $singletons
Для простых регистраций (без замыканий) можно использовать массивы-свойства провайдера. Laravel автоматически выполнит соответствующие bind и singleton.
class AppServiceProvider extends ServiceProvider
{
public array $bindings = [
UserRepository::class => EloquentUserRepository::class,
Mailer::class => SmtpMailer::class,
];
public array $singletons = [
PaymentGateway::class => StripeGateway::class,
CacheManager::class => RedisCacheManager::class,
];
// register() и boot() не обязательно определять, если используются массивы.
}
Эта форма годится для простых ассоциаций «интерфейс → класс». Для замыканий-фабрик и сложной логики — обычные register/boot.
Сводная таблица
| Метод | Когда выполняется | Что можно | Что нельзя |
|---|---|---|---|
register() | Первая фаза, до загрузки всех провайдеров | bind/singleton, контекст, теги, deferred-метаданные | Использовать другие сервисы, фасады, маршруты |
boot() | Вторая фаза, после регистрации всех | Использовать любые сервисы, регистрировать маршруты, observers, listeners, директивы | (всё доступно) |
booted() колбэк | После завершения всех boot | Финальные инициализации, требующие полностью готового приложения | (всё доступно) |
$bindings/$singletons | Автоматически выполняются в register | Простые «интерфейс → класс» ассоциации | Замыкания, сложная логика, контекстные binding |
Рассмотрим провайдер модуля Notifications с разными типами инициализации, и покажем, какой код в какой фазе должен находиться.
namespace App\Modules\Notifications\Providers;
use App\Modules\Notifications\Channels\PushChannel;
use App\Modules\Notifications\Channels\SlackChannel;
use App\Modules\Notifications\Channels\SmsChannel;
use App\Modules\Notifications\Contracts\NotificationChannel;
use App\Modules\Notifications\Listeners\BroadcastUserEvent;
use App\Modules\Notifications\Models\NotificationLog;
use App\Modules\Notifications\Observers\NotificationLogObserver;
use App\Modules\Notifications\Routes;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class NotificationsServiceProvider extends ServiceProvider
{
// Простые ассоциации.
public array $singletons = [
PushChannel::class => PushChannel::class,
SlackChannel::class => SlackChannel::class,
SmsChannel::class => SmsChannel::class,
];
public function register(): void
{
// Контекстный binding — выполняется при разрешении.
$this->app->when([SmsChannel::class])
->needs('$apiKey')
->giveConfig('notifications.sms.api_key');
// Слияние дефолтной конфигурации.
$this->mergeConfigFrom(__DIR__ . '/../config/notifications.php', 'notifications');
// Tagging — пометки в контейнере, не вызов сервисов.
$this->app->tag([
PushChannel::class,
SlackChannel::class,
SmsChannel::class,
], 'notification-channels');
}
public function boot(Dispatcher $events): void
{
// Маршруты — фасад Route доступен.
Route::middleware('web')
->prefix('notifications')
->group(__DIR__ . '/../routes/web.php');
// Миграции и шаблоны.
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'notifications');
// Observers — модель уже загружена и Eloquent готов.
NotificationLog::observe(NotificationLogObserver::class);
// Event listeners через method-injected dispatcher.
$events->listen(
\App\Events\UserRegistered::class,
BroadcastUserEvent::class,
);
// Blade-директива.
Blade::directive('notify', fn ($expr) =>
"<?php echo notify($expr); ?>");
// Финальная инициализация, когда все провайдеры загружены.
$this->app->booted(function () {
// Здесь гарантированно все сервисы готовы.
\Illuminate\Notifications\ChannelManager::macro('allOf', function (array $channels) {
// macro для удобного API.
});
});
}
}
Сводно: register — декларация (что есть в контейнере); boot — реализация (что приложение делает при старте); booted — финальные коррекции, требующие готовности всех модулей.
register(). Конструкция Route::get(...) или Storage::disk(...) в register() часто завершается ошибкой «Target class [router] does not exist» или подобной. Причина — базовый сервис фасада ещё не зарегистрирован. Перенесите вызов в boot() либо в замыкание-фабрику binding'а (которое выполнится при разрешении, а не сейчас).
app()->make($otherService) в register(). Если $otherService регистрируется в провайдере, который ещё не отработал register, попытка make вернёт несконфигурированный сервис или выкинет исключение. Зависимости провайдеров через make в register — антипаттерн; используйте конструктор инстанса (где зависимости разрешатся при первом обращении) или перенос в boot.
register(). Eloquent инициализируется в системных провайдерах; в register модели могут быть ещё не готовы. Model::observe() в register может работать на одних версиях Laravel и падать на других. Корректное место — boot().
register. Route::group() требует доступа к маршрутизатору. В register доступ может быть некорректным или маршруты не будут видны другим компонентам. Маршруты — только в boot().
register запрещён. В отличие от boot, параметры register() не разрешаются автоматически — контейнер не готов гарантированно разрешать что-либо в этот момент. Используйте $this->app внутри метода.
register(). register провайдеров вызывается на каждом запросе. Дорогая работа в нём (чтение файлов, разбор больших конфигов, обращения к сети) накапливается. Тяжёлая инициализация — либо в boot с проверкой условий, либо в Deferred Provider.
providers.php. Провайдеры регистрируются в порядке перечисления. Если провайдер B зависит от того, что A зарегистрировал, A должен идти раньше в списке. Это редко становится проблемой при правильном разделении register/boot, но в edge-cases — источник трудно отлавливаемых багов.
booted для критичного кода. Колбэки booted срабатывают после всех boot — то есть очень поздно в жизненном цикле. Если внутри booted регистрируется маршрут или middleware, они уже могут быть слишком поздно для текущего запроса. Используйте booted для дополнительных настроек, не для базовой регистрации.
Deferred Provider — провайдер, регистрация которого откладывается до момента, когда впервые понадобится один из зарегистрированных им сервисов. Это оптимизация холодного старта: вместо того чтобы инициализировать все модули на каждом запросе, фреймворк загружает только тот, чьи сервисы реально были запрошены.
Применяется для тяжёлых пакетов, используемых эпизодически: PDF-генератор, аналитический пайплайн, интеграция с внешним API, импорт/экспорт. На веб-запросах, где эти сервисы не нужны, провайдер вообще не выполняется — экономится время и ресурсы.
В обмен на оптимизацию deferred-провайдер обязан декларативно объявить, какие abstracts он будет регистрировать. Laravel хранит этот список в кеше (bootstrap/cache/services.php) и при запросе одного из этих abstracts знает, какой провайдер нужно загрузить.
Интерфейс DeferrableProvider
Для пометки провайдера как deferred класс должен реализовать интерфейс Illuminate\Contracts\Support\DeferrableProvider. Это маркерный интерфейс без методов — его наличие говорит фреймворку «загружать только по требованию». Дополнительно класс реализует метод provides(), возвращающий массив abstracts, которые регистрирует провайдер.
namespace App\Providers;
use App\Services\PdfRenderer;
use App\Services\PdfTemplateCompiler;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class PdfServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(PdfRenderer::class, function (Container $app) {
return new PdfRenderer(
compiler: $app->make(PdfTemplateCompiler::class),
fontPath: config('pdf.fonts.path'),
tempDir: storage_path('pdf/tmp'),
);
});
$this->app->singleton(PdfTemplateCompiler::class);
}
// Обязательный метод для DeferrableProvider.
public function provides(): array
{
return [
PdfRenderer::class,
PdfTemplateCompiler::class,
];
}
}
Поведение во время запроса
Laravel при старте приложения:
- Загружает кеш
services.php(если не существует — пересоздаёт черезprovides()каждого DeferrableProvider). - Для обычных провайдеров — немедленно вызывает
register(), потомboot(). - Для DeferrableProvider — запоминает соответствие «abstract → provider», но
register()не вызывает. - Когда код запрашивает
app()->make($abstract), и$abstractчислится в списке отложенных, фреймворк сначала вызываетregister()соответствующего провайдера, а затем выполняет разрешение.
Кеш сервисов и php artisan config:cache
При выполнении php artisan optimize или php artisan config:cache Laravel вызывает provides() у всех deferrable-провайдеров и записывает результат в bootstrap/cache/services.php. На production этот кеш переиспользуется между запросами, что и даёт реальную экономию.
В development-окружении без кеша Laravel вызывает provides() на каждом запросе; экономия от deferred-провайдеров ограничена самим деферированным register. Поэтому критичный эффект deferred-провайдеры дают именно в production.
# Создать кеш сервисов на production.
php artisan config:cache
# Создаст bootstrap/cache/services.php с массивом deferred-провайдеров.
# Очистить при обновлении кода.
php artisan config:clear
Ограничения deferred-провайдеров
Не любой провайдер можно сделать deferred. Чтобы провайдер был корректно deferrable:
- Все его действия по инициализации должны быть в
register()— deferred провайдеры не вызываютboot()«как обычно». Точнее,boot()вызывается только в момент первого запроса сервиса изprovides(), а не в общем boot-цикле приложения. Это означает: маршруты, observers моделей, event listeners изboot()deferred-провайдера могут не сработать, если ни один из сервисов так и не был запрошен в течение запроса; - Метод
provides()должен точно перечислять все abstracts, регистрируемые вregister(); - Провайдер не должен иметь побочных эффектов, нужных «всегда» (например, регистрация event-listener на общую модель приложения).
Если эти условия не выполнены — провайдер должен быть обычным, не deferred.
Веб-приложение использует PDF-генерацию только в одной фиче (формирование счёт-фактур) и в одной Artisan-команде (массовая отправка отчётов). На остальных запросах эта подсистема не нужна. Делаем её deferred.
namespace App\Providers;
use App\Pdf\PdfEngine;
use App\Pdf\Compilers\BladePdfCompiler;
use App\Pdf\Renderers\DompdfRenderer;
use App\Pdf\Storage\PdfStorage;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class PdfServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
// Компилятор Blade-шаблонов в HTML.
$this->app->singleton(BladePdfCompiler::class);
// Renderer на базе Dompdf — тяжёлая инициализация.
$this->app->singleton(DompdfRenderer::class, function (Container $app) {
$config = config('pdf');
return new DompdfRenderer(
paperSize: $config['paper_size'] ?? 'A4',
fontPath: $config['font_path'],
enableRemote: $config['enable_remote'] ?? false,
);
});
// Хранилище сгенерированных PDF.
$this->app->singleton(PdfStorage::class, function (Container $app) {
return new PdfStorage(
disk: app('filesystem')->disk('pdf'),
retention: config('pdf.retention_days', 30),
);
});
// Высокоуровневый сервис, объединяющий всё.
$this->app->singleton(PdfEngine::class);
}
public function provides(): array
{
return [
BladePdfCompiler::class,
DompdfRenderer::class,
PdfStorage::class,
PdfEngine::class,
];
}
}
Использование — обычное. Контроллер счёта-фактуры объявляет в конструкторе PdfEngine, и фреймворк прозрачно загружает PdfServiceProvider при первом запросе:
class InvoiceController
{
public function __construct(private readonly PdfEngine $pdf) {}
// При первом обращении к /invoices/{id}/download:
// 1. Laravel ищет PdfEngine в кеше services.php → находит PdfServiceProvider.
// 2. Вызывает PdfServiceProvider::register().
// 3. Разрешает все зависимости PdfEngine рекурсивно.
// 4. Передаёт готовый engine в конструктор контроллера.
public function download(Invoice $invoice): StreamedResponse
{
return $this->pdf->render('invoices.print', ['invoice' => $invoice]);
}
}
На всех остальных запросах PdfServiceProvider не выполняется. На страницах списка товаров, поиска, корзины — нет инициализации Dompdf, не загружается несколько мегабайт классов, не создаётся временная директория. Это становится особенно заметным с десятком тяжёлых модулей в одном приложении.
provides() неполный или устаревший. Если в провайдере регистрируется сервис, не указанный в provides(), обращение к нему через make не запустит провайдер — abstract будет считаться неизвестным, и Laravel попытается autowire или выкинет исключение. Всегда поддерживайте provides() в актуальном состоянии при добавлении новых binding'ов.
boot() в deferred-провайдере. Метод boot() в deferred-провайдере вызывается только когда первый сервис из provides() разрешён. На запросах, где сервисы не нужны, boot() не выполняется. Если в boot() регистрируются маршруты, observers, event listeners — они не сработают на «холодных» запросах. Решение — не делать провайдер deferred, либо вынести инициализацию маршрутов и т.п. в обычный провайдер.
provides() необходимо очистить кеш через php artisan optimize:clear или config:clear. Иначе Laravel будет использовать устаревший список deferred-провайдеров. На production это особенно важно: deploy-скрипты обычно вызывают config:cache для пересоздания кеша.
provides() может превышать выигрыш.
register(), но в самом тесте этот сервис не используется, провайдер не запустится. Принудительный запуск: app(SomeServiceFromProvides::class) или app()->register(PdfServiceProvider::class).
provides() упомянули один и тот же abstract, поведение зависит от порядка в services.php — и не гарантируется. Один и тот же abstract должен быть «закреплён» за единственным провайдером.
Package Provider — ServiceProvider, поставляемый в составе Composer-пакета. По функциональности не отличается от приложенческого провайдера, но имеет ряд дополнительных задач: автоматическая регистрация через mechanism Laravel auto-discovery, публикация ресурсов (конфиги, миграции, assets, views) в приложение пользователя пакета, слияние дефолтной конфигурации с пользовательской переопределённой.
Package Providers — основной механизм расширения Laravel: spatie-пакеты, Nova, Sanctum, Telescope, Pulse — всё это пакеты со своими провайдерами. Понимание устройства таких провайдеров критично для двух задач: использования сторонних пакетов с осознанным выбором (включить только нужное, не зарегистрировать всё подряд) и написания собственных пакетов под нужды команды.
Provider пакета должен быть «вежливым»: не перетирать пользовательские настройки, давать возможность точечной публикации только нужных частей, поддерживать config:cache в production, не делать heavy work на каждом запросе. Эти ожидания формируют конкретные паттерны кода в register() и boot().
Auto-discovery через composer.json
В composer.json пакета объявляется секция extra.laravel с перечислением провайдеров и aliases. Когда пакет устанавливается через composer require, Laravel автоматически регистрирует его провайдер — пользователю не нужно править bootstrap/providers.php.
// composer.json пакета
{
"name": "acme/payment-kit",
"description": "Unified payment gateway abstraction",
"require": { "php": "^8.2", "illuminate/support": "^11.0|^12.0" },
"autoload": {
"psr-4": { "Acme\\PaymentKit\\\\": "src/" }
},
"extra": {
"laravel": {
"providers": [
"Acme\\PaymentKit\\PaymentKitServiceProvider"
],
"aliases": {
"Payment": "Acme\\PaymentKit\\Facades\\Payment"
}
}
}
}
Пользователь, отказавшийся от автозагрузки конкретного пакета, добавляет его в extra.laravel.dont-discover своего composer.json:
{
"extra": {
"laravel": {
"dont-discover": ["acme/payment-kit"]
}
}
}
Слияние конфигурации: mergeConfigFrom
Пакет должен предоставлять разумные дефолты, но позволять пользователю их переопределять. Метод mergeConfigFrom в register() сливает дефолтный конфиг пакета с пользовательским (если последний существует) — пользовательские значения побеждают.
public function register(): void
{
// Подгружает config/payment-kit.php приложения, если есть,
// и сливает с дефолтами пакета.
$this->mergeConfigFrom(
path: __DIR__ . '/../config/payment-kit.php',
key: 'payment-kit',
);
}
Дефолтный config/payment-kit.php внутри пакета содержит все ключи с разумными значениями. Пользователь публикует его в своё приложение (см. ниже) и переопределяет только то, что нужно.
Публикация ресурсов: publishes
Через publishes() провайдер пакета объявляет, какие файлы могут быть скопированы в приложение пользователя командой php artisan vendor:publish. Файлы группируются по тегам, что позволяет публиковать только нужные части.
public function boot(): void
{
// Конфиг.
$this->publishes([
__DIR__ . '/../config/payment-kit.php' => config_path('payment-kit.php'),
], 'payment-kit-config');
// Миграции (с timestamp в имени файла).
$this->publishes([
__DIR__ . '/../database/migrations/' => database_path('migrations'),
], 'payment-kit-migrations');
// Views.
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/payment-kit'),
], 'payment-kit-views');
// Assets (CSS/JS).
$this->publishes([
__DIR__ . '/../public' => public_path('vendor/payment-kit'),
], 'payment-kit-assets');
}
Пользователь публикует выборочно:
# Только конфиг.
php artisan vendor:publish --tag=payment-kit-config
# Только миграции.
php artisan vendor:publish --tag=payment-kit-migrations
# Всё разом (все теги пакета).
php artisan vendor:publish --provider="Acme\\PaymentKit\\PaymentKitServiceProvider"
Загрузка ресурсов без публикации
Многие ресурсы пакета не требуют публикации — они подгружаются непосредственно из директорий пакета. Это касается миграций (если пользователь готов получать их обновления автоматически), views (с собственным namespace), translations, маршрутов.
public function boot(): void
{
// Миграции работают «из пакета» — обновляются автоматически с обновлением пакета.
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Views с namespace pkg::view-name.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'payment-kit');
// Использование в Blade: @extends('payment-kit::layouts.app')
// Translations с namespace pkg::group.key.
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'payment-kit');
// Использование: __('payment-kit::messages.charge_failed')
// Routes.
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
}
Регистрация фасадов и Artisan-команд
Если пакет предоставляет фасад, сам класс фасада объявляется в коде пакета (наследует Illuminate\Support\Facades\Facade), а его alias регистрируется через composer.json (см. выше) или явно через AliasLoader в провайдере.
Artisan-команды регистрируются в boot() с проверкой $this->app->runningInConsole(), чтобы не загружать их на веб-запросах.
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\Acme\PaymentKit\Console\SyncTransactionsCommand::class,
\Acme\PaymentKit\Console\ReconcileBalancesCommand::class,
]);
}
}
Чек-лист провайдера пакета
| Действие | Где | Зачем |
|---|---|---|
mergeConfigFrom(...) | register | Дефолты + пользовательские override |
bind/singleton сервисов | register | Регистрация контейнерных биндингов |
publishes(...) | boot | Объявить, что можно публиковать |
loadMigrationsFrom(...) | boot | Миграции «из пакета» |
loadViewsFrom(..., 'ns') | boot | Blade-templates с namespace |
loadTranslationsFrom(...) | boot | Переводы |
loadRoutesFrom(...) | boot | Маршруты пакета |
$this->commands(...) | boot + runningInConsole | Artisan-команды |
Model::observe(...) | boot | Observers моделей пакета |
Рассмотрим небольшой пакет acme/audit-log — журналирование операций с прокрутки моделей. Он содержит: одну модель AuditEntry, миграцию, фасад Audit, конфиг с настройкой подключения и retention-политикой, две Artisan-команды для очистки.
// composer.json пакета
{
"name": "acme/audit-log",
"description": "Domain-agnostic audit logging for Laravel",
"autoload": { "psr-4": { "Acme\\AuditLog\\\\": "src/" } },
"extra": {
"laravel": {
"providers": ["Acme\\AuditLog\\AuditLogServiceProvider"],
"aliases": { "Audit": "Acme\\AuditLog\\Facades\\Audit" }
}
}
}
// src/AuditLogServiceProvider.php
namespace Acme\AuditLog;
use Acme\AuditLog\Console\PruneAuditCommand;
use Acme\AuditLog\Console\ExportAuditCommand;
use Acme\AuditLog\Contracts\AuditRecorder;
use Acme\AuditLog\Services\DatabaseAuditRecorder;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
class AuditLogServiceProvider extends ServiceProvider
{
public function register(): void
{
// 1. Сливаем дефолтный конфиг пакета.
$this->mergeConfigFrom(
__DIR__ . '/../config/audit.php', 'audit'
);
// 2. Связываем контракт с реализацией. Singleton — recorder stateless.
$this->app->singleton(AuditRecorder::class, function (Container $app) {
return new DatabaseAuditRecorder(
connection: config('audit.connection') ?? config('database.default'),
table: config('audit.table', 'audit_entries'),
);
});
// 3. Регистрируем alias для удобства: app('audit').
$this->app->alias(AuditRecorder::class, 'audit');
}
public function boot(): void
{
// 4. Объявляем публикуемые ресурсы с раздельными тегами.
$this->publishes([
__DIR__ . '/../config/audit.php' => config_path('audit.php'),
], 'audit-config');
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'audit-migrations');
// 5. Загружаем миграции «из пакета» — обновляются с пакетом.
// (Альтернатива: пусть пользователь публикует и контролирует сам.)
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// 6. Регистрируем Artisan-команды только в CLI-окружении.
if ($this->app->runningInConsole()) {
$this->commands([
PruneAuditCommand::class,
ExportAuditCommand::class,
]);
}
}
}
Использование на стороне пользователя:
# Установка
composer require acme/audit-log
# Опционально: опубликовать конфиг для кастомизации.
php artisan vendor:publish --tag=audit-config
# Миграции уже автоматически подхватятся следующим artisan migrate.
php artisan migrate
// В коде пользователя
use Acme\AuditLog\Contracts\AuditRecorder;
class OrderController
{
public function __construct(private readonly AuditRecorder $audit) {}
public function cancel(Order $order): Response
{
$order->cancel();
$this->audit->record('order.cancelled', [
'order_id' => $order->id,
'reason' => request('reason'),
]);
return response(...);
}
}
// Или через фасад:
Audit::record('order.cancelled', [...]);
Достоинства пакетной структуры: возможность переиспользования между проектами без копипасты, версионирование через Composer, понятная история обновлений, явные публичные контракты в виде интерфейсов.
php artisan route:cache, маршруты пакета должны быть включены в кеш. Метод loadRoutesFrom корректно интегрируется с механизмом кеширования; ручная регистрация через Route::get в провайдере — нет.
publishes для не существующих файлов. Если путь источника в publishes указан неверно или файл не существует на момент выполнения, vendor:publish молча проигнорирует его. Тестируйте публикацию на чистой установке.
publishes (например, 'config'), при vendor:publish --tag=config опубликуются оба — иногда нежелательно. Используйте уникальные префиксы: audit-config, payment-kit-config.
mergeConfigFrom и глубокий merge. Стандартный mergeConfigFrom делает shallow merge: пользовательские значения первого уровня перезаписывают дефолтные, но вложенные ключи — нет. Это часто приводит к тому, что пользователь, переопределяя один параметр в массиве, теряет остальные. Решение — рекурсивный merge через array_replace_recursive в провайдере, либо документировать как должен выглядеть полный конфиг.
composer.json. Использование illuminate/support: "^11.0" исключает поддержку 12.x — пользователь не сможет обновиться. Тестируйте на нескольких версиях, расширяйте constraints по мере подтверждения совместимости.
register пакета. Пакет может быть deferred, чтобы не загружаться на запросах, где он не нужен. Это особенно важно для пакетов уровня инфраструктуры (PDF, аналитика, отчёты), которые включаются в большинство проектов, но используются редко.
dont-discover), провайдер не загрузится. Документируйте, что в этом случае пользователю нужно вручную добавить провайдер в bootstrap/providers.php.
vendor:publish по умолчанию не перезаписывает существующие файлы. Документация и опция --force — единственные средства уведомить пользователя.
Задача — собрать модуль Billing, демонстрирующий все рассмотренные техники контейнера в одном сценарии. Требования к модулю:
- контракт
PaymentGatewayс двумя реализациями: StripeGateway (продакшен) и FakeGateway (тесты, локальная разработка); - сервис
SubscriptionServiceработает только со Stripe (фиксированно по бизнес-требованию); - сервис
CheckoutServiceвыбирает реализацию по конфигурации мерчанта (контекстуально на уровне арендатора); - набор правил риска (
RiskRule) подключается через теги — их количество растёт независимо от ядра; - в рамках одного HTTP-запроса используется общий
BillingContext(идемпотентность операций) —scopedбиндинг; - провайдер модуля — deferred, потому что биллинг затрагивает только маршруты checkout/subscription;
- модуль оформлен как отдельный пакет
acme/billingс auto-discovery, публикацией конфигурации и миграциями.
packages/acme/billing/
├── composer.json
├── config/
│ └── billing.php
├── database/
│ └── migrations/
│ └── 2026_05_22_000001_create_billing_charges_table.php
├── routes/
│ └── web.php
├── src/
│ ├── Contracts/
│ │ └── PaymentGateway.php
│ ├── Gateways/
│ │ ├── StripeGateway.php
│ │ └── FakeGateway.php
│ ├── Risk/
│ │ ├── RiskRule.php
│ │ ├── VelocityRule.php
│ │ ├── GeoMismatchRule.php
│ │ ├── BlocklistRule.php
│ │ └── RiskEngine.php
│ ├── Services/
│ │ ├── BillingContext.php
│ │ ├── CheckoutService.php
│ │ └── SubscriptionService.php
│ └── Providers/
│ └── BillingServiceProvider.php
└── tests/
└── ...
// packages/acme/billing/src/Contracts/PaymentGateway.php
namespace Acme\Billing\Contracts;
interface PaymentGateway
{
public function charge(int $amountMinor, string $currency, string $idempotencyKey): string;
public function refund(string $chargeId, ?int $amountMinor = null): void;
}
// packages/acme/billing/src/Gateways/StripeGateway.php
namespace Acme\Billing\Gateways;
use Acme\Billing\Contracts\PaymentGateway;
use Stripe\StripeClient;
final class StripeGateway implements PaymentGateway
{
public function __construct(private StripeClient $stripe) {}
public function charge(int $amountMinor, string $currency, string $idempotencyKey): string
{
$intent = $this->stripe->paymentIntents->create(
['amount' => $amountMinor, 'currency' => $currency],
['idempotency_key' => $idempotencyKey],
);
return $intent->id;
}
public function refund(string $chargeId, ?int $amountMinor = null): void
{
$this->stripe->refunds->create(['payment_intent' => $chargeId, 'amount' => $amountMinor]);
}
}
// packages/acme/billing/src/Gateways/FakeGateway.php — детерминированная реализация для тестов
namespace Acme\Billing\Gateways;
use Acme\Billing\Contracts\PaymentGateway;
final class FakeGateway implements PaymentGateway
{
public array $calls = [];
public function charge(int $amountMinor, string $currency, string $idempotencyKey): string
{
$this->calls[] = compact('amountMinor', 'currency', 'idempotencyKey');
return 'fake_' . substr(sha1($idempotencyKey), 0, 12);
}
public function refund(string $chargeId, ?int $amountMinor = null): void {}
}
Каждое правило риска реализует общий контракт. Новое правило добавляется без правок ядра — провайдер тегирует его, а RiskEngine получает все теги одной выборкой.
// packages/acme/billing/src/Risk/RiskRule.php
namespace Acme\Billing\Risk;
interface RiskRule
{
public function code(): string;
public function evaluate(array $payload): int; // 0..100 — вклад в общий риск-скор
}
// packages/acme/billing/src/Risk/RiskEngine.php
namespace Acme\Billing\Risk;
final class RiskEngine
{
/** @param iterable<RiskRule> $rules */
public function __construct(private iterable $rules) {}
public function score(array $payload): int
{
$total = 0;
foreach ($this->rules as $rule) {
$total += $rule->evaluate($payload);
}
return min(100, $total);
}
}
// packages/acme/billing/src/Services/BillingContext.php
namespace Acme\Billing\Services;
final class BillingContext
{
public string $correlationId;
public array $idempotencyKeys = [];
public function __construct()
{
$this->correlationId = bin2hex(random_bytes(8));
}
public function keyFor(string $operation): string
{
return $this->idempotencyKeys[$operation] ??= bin2hex(random_bytes(16));
}
}
// packages/acme/billing/src/Providers/BillingServiceProvider.php
namespace Acme\Billing\Providers;
use Acme\Billing\Contracts\PaymentGateway;
use Acme\Billing\Gateways\FakeGateway;
use Acme\Billing\Gateways\StripeGateway;
use Acme\Billing\Risk\BlocklistRule;
use Acme\Billing\Risk\GeoMismatchRule;
use Acme\Billing\Risk\RiskEngine;
use Acme\Billing\Risk\VelocityRule;
use Acme\Billing\Services\BillingContext;
use Acme\Billing\Services\CheckoutService;
use Acme\Billing\Services\SubscriptionService;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
final class BillingServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/billing.php', 'billing');
// 1. Stripe-клиент — singleton, чтобы переиспользовать соединение.
$this->app->singleton(StripeClient::class, fn () =>
new StripeClient(config('billing.stripe.secret')));
// 2. Дефолтная реализация контракта зависит от окружения.
$this->app->singleton(PaymentGateway::class, function ($app) {
return $app->environment('testing', 'local')
? new FakeGateway()
: $app->make(StripeGateway::class);
});
// 3. Подписки всегда обслуживает Stripe — независимо от настроек мерчанта.
$this->app->when(SubscriptionService::class)
->needs(PaymentGateway::class)
->give(StripeGateway::class);
// 4. Контекст запроса — scoped, общий для всех потребителей в рамках запроса.
$this->app->scoped(BillingContext::class);
// 5. Правила риска тегируем — RiskEngine получит iterable через все теги.
$this->app->tag([
VelocityRule::class,
GeoMismatchRule::class,
BlocklistRule::class,
], 'billing.risk-rules');
$this->app->singleton(RiskEngine::class, fn ($app) =>
new RiskEngine($app->tagged('billing.risk-rules')));
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadRoutesFrom(__DIR__ . '/../../routes/web.php');
if ($this->app()->runningInConsole()) {
$this->publishes([
__DIR__ . '/../../config/billing.php' => config_path('billing.php'),
], 'billing-config');
}
}
public function provides(): array
{
return [
PaymentGateway::class,
StripeClient::class,
BillingContext::class,
RiskEngine::class,
];
}
}
- singleton для
StripeClient— дорогой ресурс с состоянием соединения, переиспользуется в рамках процесса; - зависимая фабрика для
PaymentGateway— реализация выбирается на основании окружения, контейнер прячет ветвление от потребителей; - contextual переопределение для
SubscriptionService— жёстко гарантирует одну реализацию вне зависимости от глобального дефолта; - scoped биндинг
BillingContext— единыйcorrelationIdпо всем потребителям одного запроса, в очередях контекст создаётся заново; - tag/tagged —
RiskEngineрасширяется новыми правилами без правки конструктора и без указания провайдера-агрегатора; - DeferrableProvider — модуль не загружается на запросах, не затрагивающих биллинг, что снижает накладные расходы на «холодных» маршрутах;
- publishes с уникальным тегом
billing-config— конфиг можно опубликовать, не задевая другие пакеты; - composer.json + auto-discovery — провайдер регистрируется автоматически после
composer require acme/billing.
// app/Http/Controllers/CheckoutController.php — потребитель пакета
final class CheckoutController
{
public function store(
CheckoutRequest $request,
CheckoutService $checkout,
RiskEngine $risk,
BillingContext $ctx,
) {
$score = $risk->score($request->validated());
abort_if($score >= 80, 422, 'High-risk transaction');
$chargeId = $checkout->charge(
amountMinor: $request->integer('amount_minor'),
currency: $request->string('currency'),
idempotencyKey: $ctx->keyFor('checkout'),
);
return response()->json(['charge_id' => $chargeId, 'correlation' => $ctx->correlationId]);
}
}
В тестах app()->instance(PaymentGateway::class, $fake) подменяет шлюз на изолированный мок без модификации провайдера. Запросный BillingContext и RiskEngine остаются собственными — именно так контейнер делает тестируемыми даже сложные модули.
Каждый из камней относится к одной из подсекций KB_13 и приведён здесь в дайджест-форме для быстрого аудита провайдеров и сервисов.
forgetScopedInstances. Запросные данные предыдущего пользователя остаются доступны следующему. Решение — не инжектить scoped в singleton; принимать его в методе или через app(BillingContext::class) по требованию.
app()->make() в коде. Каждый make — явный вызов сервис-локатора, скрывающий настоящие зависимости класса. Чем больше make, тем сложнее тестировать и рефакторить. Используйте конструкторное внедрение, оставляя make для фабрик и фасадов.
register(). Чтение файлов, HTTP-запросы, разрешение зависимостей через make в register выполняются на каждом запросе и до того, как контейнер полностью готов. Дорогая инициализация — в boot, ещё лучше — в deferred-провайдер.
register(). Route::get, Schedule::command, Gate::define в register часто падают с Target class does not exist: соответствующий сервис ещё не зарегистрирован. Любое использование фасадов — в boot().
singleton для сервисов с состоянием запроса. Корзина, авторизованный пользователь, мультитенантный контекст должны быть scoped, не singleton. Singleton переживёт несколько запросов в long-running окружениях (Octane, RoadRunner) и приведёт к утечке данных.
collect($container->tagged(...))->all(). Метод tagged возвращает iterable с ленивой инициализацией. Принудительное приведение к массиву через collect()->all() или iterator_to_array заставляет инстанцировать все сервисы тега даже если используется один. Принимайте iterable и пробегайте foreach.
boot. Идея deferred — не платить за модуль до первого обращения. Если в boot провайдера тяжёлая инициализация, она ударит по производительности именно того запроса, который первым активировал модуль. Минимизируйте boot deferred-провайдера; держите в register только биндинги.
$singletons. Свойство пригодно только для простых ассоциаций abstract → concrete. Любая нестандартная инициализация (зависит от конфига, окружения, условий) — через $this->app->singleton(...) с замыканием в register().
instance() вместо singleton(). instance($value) кладёт уже созданный объект, ломая ленивую инициализацию. Если объект не нужен сейчас — singleton с фабрикой. instance уместен для тестовых моков и для разрешения циклов вручную.
contextual без singleton в дефолте. Если базовая абстракция не имеет binding'а, contextual binding для одного потребителя сработает, а другие получат ошибку разрешения. Всегда регистрируйте дефолт через singleton/bind и переопределяйте контекстно по требованию.
resolving-колбэки без after. resolving вызывается до того, как контейнер закончил инжекцию зависимостей. Для безопасного пост-настройки (например, добавление подписчиков события) используйте afterResolving.
dont-discover), провайдер не подхватится. Документируйте альтернативу — ручное добавление в bootstrap/providers.php.
1. Чем Inversion of Control отличается от Dependency Injection?
IoC — общий архитектурный принцип: управление потоком (создание объектов, вызов методов, реакция на события) передаётся внешнему компоненту. DI — конкретный приём реализации IoC: зависимости класса передаются ему извне, а не создаются им самим. DI без IoC-контейнера возможен — ручное конструирование через new. IoC-контейнер автоматизирует DI и решает задачу разрешения графа зависимостей.
2. Что произойдёт, если внедрить в конструктор сервиса несуществующий контракт без binding'а?
Auto-wiring пробует инстанцировать класс/интерфейс. Для конкретного класса — вызовет new, рекурсивно разрешая параметры. Для интерфейса без binding'а контейнер бросит BindingResolutionException с сообщением о невозможности разрешить абстракцию. Решение — зарегистрировать реализацию через bind/singleton.
3. Чем bind отличается от singleton?
bind создаёт новый экземпляр на каждое make; singleton создаёт один и кеширует его до завершения процесса. singleton уместен для сервисов без запросного состояния (репозитории, клиенты внешних API, кеш-абстракции); bind — для сервисов с per-resolution состоянием.
4. Зачем в провайдере две фазы: register и boot?
register исполняется первым у всех провайдеров — в этой фазе нельзя зависеть от других сервисов, доступен только $this->app для регистрации binding'ов. boot вызывается после того, как все провайдеры завершили register — гарантированно доступен фасад любого зарегистрированного сервиса. Разделение исключает циклические зависимости при загрузке.
5. Как работает DeferrableProvider и когда он оправдан?
Deferred-провайдер не вызывается на каждом запросе — вместо этого Laravel сохраняет карту сервис → провайдер в bootstrap/cache/services.php. При первом обращении к сервису через контейнер провайдер регистрируется. Оправдан для модулей, которые активны только на части маршрутов (биллинг, генерация отчётов, интеграция с внешним API). Не подходит, если в boot нужна логика, выполняющаяся всегда (регистрация глобальных middleware, scheduled-задач).
6. Что делает $this->app->booted(fn) и чем отличается от boot()?
booted ставит колбэк в очередь, исполняемую после того, как все провайдеры завершили boot. В этот момент гарантированно доступны все сервисы, маршруты, миддлвары, события. Уместно для логики, которой нужно «свести все провайдеры вместе» (например, регистрация macro у класса, который определяется в другом пакете). В сам boot() это нельзя положить безопасно, поскольку порядок boot-методов между провайдерами зависит от порядка регистрации.
7. Когда применять contextual binding вместо отдельных контрактов?
Когда два потребителя реализуют одну и ту же концепцию (например, Filesystem), но должны работать с разными конкретными реализациями (один — локальный диск, другой — S3). Вводить два разных интерфейса означало бы дублировать абстракцию ради конфигурации — вместо этого contextual binding оставляет общий контракт и переопределяет реализацию для конкретного класса.
8. Чем tagged-сервисы отличаются от инжекта массива?
tagged возвращает iterable с ленивой инициализацией — сервис создаётся только в момент итерации. Это критично, если в теге много сервисов, а используется один. Инжект массива ([ServiceA::class, ServiceB::class]) требует немедленного разрешения всех элементов. Кроме того, через tagged можно добавлять новых участников из других провайдеров (например, пакетов-расширений) без модификации потребителя.
9. Что такое scoped binding и когда он необходим?
scoped — binding, который ведёт себя как singleton в пределах одного «scope» (HTTP-запрос или Job в очереди), но сбрасывается между scope'ами. Необходим в long-running окружениях (Laravel Octane, RoadRunner, FrankenPHP), где singleton переживает запросы и протекает запросное состояние между пользователями. В стандартном PHP-FPM scoped и singleton ведут себя одинаково, поскольку процесс умирает после запроса.
10. Как Laravel разрешает конфликт между интерфейсом и его реализацией при auto-wiring?
Контейнер использует имя типа из аргумента конструктора. Если это интерфейс — ищет binding в $bindings/$instances/$contextual. При отсутствии — BindingResolutionException. Если конкретный класс — пытается инстанцировать его рекурсивно. Если binding объявлен и не подходит по сигнатуре (например, реализация не имплементирует интерфейс), возникает TypeError, а не ошибка контейнера.
11. Что включает в себя auto-discovery провайдера в Laravel-пакете?
В composer.json пакета указывается секция extra.laravel.providers и (опционально) extra.laravel.aliases. При composer require Laravel парсит эти данные, добавляет провайдер в кеш загружаемых провайдеров и регистрирует фасады. Пользователь может отключить auto-discovery для конкретного пакета через extra.laravel.dont-discover в своём composer.json.
12. Чем mergeConfigFrom отличается от publishes для конфигурации?
mergeConfigFrom накладывает дефолты пакета на конфиг пользователя — пользовательские значения первого уровня перекрывают пакетные. Работает в рантайме, без модификации файлов проекта. publishes копирует файл конфига в config/ проекта при vendor:publish — пользователь получает полный файл, который можно редактировать. Часто эти подходы используют вместе: mergeConfigFrom обеспечивает работу из коробки, publishes — для тонкой настройки.
13. Какие потенциальные проблемы возникают, если положить логику в свойство $singletons провайдера?
Свойство принимает только ассоциации abstract → concrete — никакой логики, замыканий или зависимости от конфига. Если binding нужно строить с условиями (по окружению, по фиче-флагу, по версии PHP), такая логика обязательно должна быть в register(). Кроме того, $singletons подразумевает разрешение без явного singleton() — что усложняет отладку, если кто-то ищет регистрацию по grep в коде.
14. Как контейнер взаимодействует с PSR-11?
Laravel'овский Container имплементирует Psr\Container\ContainerInterface — методы get($id) и has($id) являются обёртками над make и bound. Это позволяет интегрировать контейнер в библиотеки, ожидающие PSR-11 (Slim, ReactPHP-based фреймворки). Обратное направление — передать PSR-11-контейнер в качестве источника resolve — в Laravel напрямую не поддерживается.
15. Как тестировать сервис, использующий app() внутри метода вместо инжекта в конструктор?
В тесте до вызова метода подменяется binding: app()->instance(SomeService::class, $fake) или $this->swap(SomeService::class, $fake). После этого внутренний app(SomeService::class) вернёт mock. Подход рабочий, но менее предпочтителен, чем конструкторная инъекция: зависимости неявны, IDE не подсказывает их, рефакторинг сложнее. Используйте app() внутри метода только для редких, ситуативных зависимостей.