Сервис-контейнер и провайдеры в Laravel: приёмы из практики
Когда bind, когда singleton, зачем нужен deferred-провайдер и почему контекстный биндинг спасает от кучи флагов в конструкторе. Разбор на конкретных сценариях для Laravel 11.x.

Вы завели интерфейс PaymentGateway, реализовали его через Stripe, а на тесте нужен фейк. Или у вас два получателя одной и той же зависимости, но один хочет писать в S3, а другой — в локальный диск. Или приложение стало заметно медленнее стартовать, потому что каждый провайдер тянет за собой SDK, который в 90% запросов не используется. Всё это — задачи сервис-контейнера, и решаются они не в конструкторах, а в биндингах. Разбираем рабочие приёмы на Laravel 11.x: где bind, где singleton, зачем контекстный биндинг и когда провайдер стоит сделать отложенным.
Что контейнер делает за вас
Контейнер Laravel — это фабрика с автоматическим разрешением зависимостей. Когда вы просите класс через app()->make() или type-hint в конструкторе контроллера, контейнер смотрит на сигнатуру, рекурсивно создаёт всё, что нужно, и отдаёт готовый объект. Пока зависимости — это конкретные классы без интерфейсов, никакой ручной регистрации не нужно: автовайринг справляется сам.
Проблемы начинаются, когда в type-hint стоит интерфейс. Контейнер не знает, какую реализацию подставить, и падает с Target [App\Contracts\PaymentGateway] is not instantiable. Вот тут и нужен биндинг в сервис-провайдере.
bind против singleton
Два основных метода отличаются жизненным циклом объекта.
- bind — замыкание вызывается каждый раз, когда зависимость запрашивают. Новый объект на каждый
make. Подходит для того, что хранит состояние в рамках одной операции: билдеры, DTO-фабрики, обёртки над запросом. - singleton — замыкание вызывается один раз за жизнь контейнера, результат кешируется. Подходит для клиентов внешних API, конфигурированных сервисов, пулов соединений — всего, что дорого создавать и не хранит запросо-зависимого состояния.
Ошибка, которую видно в ревью постоянно: HTTP-клиент к платёжному шлюзу зарегистрирован через bind. На каждый запрос к нему пересоздаётся объект, читается конфиг, инициализируется guzzle-стек. Под нагрузкой это лишние аллокации на ровном месте.
// app/Providers/AppServiceProvider.php (Laravel 11.x)
use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
public function register(): void
{
// Клиент дорогой и без состояния — singleton
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(
secret: config('services.stripe.secret'),
httpTimeout: config('services.stripe.timeout', 10),
);
});
}
Правило первого приближения: если объект дорого создать и он не держит данных конкретного запроса — singleton. Всё остальное — bind. Сомневаетесь — bind: лишний объект дешевле, чем разделённое состояние между запросами в очереди или Octane.
register и boot — не одно и то же
В провайдере два метода, и путать их — источник плавающих багов.
- register — только регистрируете биндинги. Нельзя обращаться к другим сервисам: они могут быть ещё не зарегистрированы. Никаких
$this->app->make()здесь. - boot — вызывается после того, как отработали все
registerвсех провайдеров. Здесь уже можно разрешать зависимости, регистрировать слушатели событий, макросы, вью-композеры.
Если в register дёрнуть сервис из ещё не загруженного провайдера, вы получите либо неполно сконфигурированный объект, либо ошибку разрешения. Порядок загрузки провайдеров не гарантирован по вашему желанию — не полагайтесь на него.
Контекстный биндинг: одна зависимость, разные реализации
Классический сценарий. Есть интерфейс Filesystem. Сервис отчётов должен писать в S3, сервис временных файлов — на локальный диск. Оба принимают один и тот же интерфейс в конструктор. Заводить два интерфейса ради этого — плодить сущности. Контекстный биндинг решает это точечно.
use App\Contracts\Storage;
use App\Services\ReportExporter;
use App\Services\TempFileManager;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage as StorageFacade;
public function register(): void
{
$this->app->when(ReportExporter::class)
->needs(Filesystem::class)
->give(fn () => StorageFacade::disk('s3'));
$this->app->when(TempFileManager::class)
->needs(Filesystem::class)
->give(fn () => StorageFacade::disk('local'));
}
Теперь ReportExporter получит S3, TempFileManager — локальный диск, и ни один из них не знает про эту развилку. Альтернатива через флаг в конструкторе (new Exporter(useS3: true)) размазывает знание о конфигурации по коду и мешает тестам.
Когда контекстный биндинг — перебор
Если реализация одна на всё приложение — контекстный биндинг не нужен, обычный bind проще. Если развилок больше двух-трёх на один интерфейс — это сигнал, что интерфейс делает слишком много и его пора разбить. Контекстный биндинг хорош для двух-трёх явных потребителей, а не как замена нормальной декомпозиции.
Deferred-провайдеры: не грузить то, что не нужно
Каждый провайдер в списке bootstrap/providers.php регистрируется на каждый запрос. Если провайдер тянет тяжёлый SDK, который нужен в одном эндпоинте из ста, вы платите за инициализацию на всех остальных запросах.
Отложенный провайдер регистрируется лениво: контейнер запоминает, какие биндинги он предоставляет, и вызывает register только тогда, когда одну из этих зависимостей реально запросят.
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
class PaymentServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(config('services.stripe.secret'));
});
}
// Обязательно: контейнер по этому списку понимает,
// когда провайдер пора поднять
public function provides(): array
{
return [PaymentGateway::class];
}
}
Важное ограничение: отложенный провайдер работает только через register. Если у провайдера есть boot с регистрацией событий или роутов — откладывать его нельзя, потому что boot у deferred-провайдера не вызывается до момента разрешения, а к тому времени событие могло уже пройти. Правило: deferred подходит для провайдеров, которые только биндят сервисы и ничего не делают на старте приложения.
Что выбирать
| Ситуация | Приём |
|---|---|
| Конкретный класс без интерфейса | Ничего, работает автовайринг |
| Интерфейс с одной реализацией на всё приложение | bind или singleton |
| Дорогой клиент без запросо-зависимого состояния | singleton |
| Объект хранит состояние операции/запроса | bind |
| Один интерфейс, 2–3 потребителя с разной реализацией | Контекстный when()->needs()->give() |
| Тяжёлый провайдер, нужный в редком эндпоинте, только биндинги | DeferrableProvider |
Что ломается на практике
- Singleton, хранящий состояние запроса, под Octane. В обычном FPM контейнер живёт один запрос, потом умирает — состояние не утекает. В Laravel Octane процесс переиспользуется между запросами, и данные предыдущего запроса протекут в следующий. Если сервис singleton — убедитесь, что он не держит user_id, request-объект или что-то запросо-специфичное.
- make() в register. Разрешение зависимости, чей провайдер ещё не отработал, даёт частично сконфигурированный объект. Переносите такое в
boot. - Deferred-провайдер с boot. Метод
bootу отложенного провайдера не сработает вовремя. Слушатели событий и роуты не зарегистрируются — молча, без ошибки. - Забытый provides(). Без него отложенный провайдер не поднимется никогда, а биндинг будет отсутствовать. Симптом —
is not instantiableна интерфейсе, который вроде бы зарегистрирован.
Что делать дальше
Пройдитесь по своим провайдерам и разметьте биндинги: что реально должно быть singleton, а что стало им «по привычке». Проверьте под нагрузкой время холодного старта приложения — если оно раздуто, ищите тяжёлые провайдеры, которые можно сделать отложенными. Если гоняете Octane, отдельно проверьте все singleton на предмет утечки состояния между запросами: это самый дорогой в отладке класс багов. Точный список методов контейнера и актуальные ограничения deferred-провайдеров сверяйте в официальной документации Laravel для вашей минорной версии — API контейнера стабилен, но детали поведения между мажорами меняются.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Laravel Documentation — Service Container, Laravel Documentation — Service Providers
Частые вопросы
У меня Laravel 10, а не 11 — эти приёмы сработают?
Если я всё зарегистрирую как singleton, будет быстрее?
Не сработал контекстный биндинг — потребитель всё равно получает не ту реализацию
Куда класть биндинг — в AppServiceProvider или в отдельный провайдер?
Как замокать singleton-сервис в тесте?
Опасен ли deferred-провайдер в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.