Сервис-контейнер и провайдеры в Laravel: как не сломать
Как правильно регистрировать зависимости в Laravel: разница между bind и singleton, зачем нужен deferred-провайдер и какие ошибки привязки всплывают только в проде.

Вы добавили в проект новый сервис, привязали его в AppServiceProvider через bind, и всё работает — до момента, когда обнаруживается, что состояние объекта неожиданно сбрасывается между вызовами внутри одного запроса. Или наоборот: singleton держит подключение к внешнему API, а вы гоняете тесты и получаете общий на весь набор объект с забитым кешем. Оба случая — про то, как именно контейнер Laravel создаёт объекты и когда. Разберём, какой способ привязки выбрать, где провайдер выстрелит в ногу и что из этого проявляется только под нагрузкой. Актуально для Laravel 11.x, но механика контейнера не менялась с 8.x.
Что делает контейнер и почему это не просто new
Сервис-контейнер — это фабрика, которая умеет создавать объекты и подставлять их зависимости автоматически. Когда вы просите класс через type-hint в конструкторе контроллера или через app()->make(), контейнер смотрит: знает ли он, как построить этот тип. Если тип — конкретный класс без зависимостей, контейнер вызовет new сам. Если это интерфейс или класс с зависимостями — ему нужна инструкция. Инструкции регистрируются в провайдерах.
Разница с ручным new в том, что контейнер разрешает всю цепочку зависимостей рекурсивно. Вы просите OrderService, а он тянет за собой PaymentGateway, тот — HTTP-клиент, тот — конфиг. Всё это собирается за один make.
Контейнер не хранит объекты по умолчанию. Каждый make для обычной привязки — это новый экземпляр. Если вам нужен один объект на весь запрос, это надо сказать явно.
bind, singleton, instance: три способа привязки
Регистрация происходит в методе register провайдера. Три основных способа и то, чем они отличаются на практике:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
// Новый экземпляр при каждом make
$this->app->bind(PaymentGateway::class, function ($app) {
return new StripeGateway(config('services.stripe.secret'));
});
// Один экземпляр на весь жизненный цикл приложения (в рамках запроса)
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(config('services.stripe.secret'));
});
// Готовый объект, который вы создали сами
$gateway = new StripeGateway(config('services.stripe.secret'));
$this->app->instance(PaymentGateway::class, $gateway);
}
}
| Способ | Когда создаётся объект | Сколько экземпляров | Типичное применение |
|---|---|---|---|
bind | При каждом make | Новый каждый раз | Объекты с состоянием, которое не должно течь между вызовами |
singleton | При первом make, потом переиспользуется | Один на запрос | Клиенты API, репозитории без состояния, дорогие в создании объекты |
instance | Вы создали заранее | Один, ваш | Когда объект уже собран или пришёл извне |
Ключевая мысль, которую пропускают новички: singleton в Laravel живёт в пределах одного HTTP-запроса на классическом PHP-FPM. Запрос завершился — контейнер уничтожен, следующий запрос строит объект заново. Это меняется, если вы работаете под Laravel Octane — там приложение держится в памяти между запросами, и singleton живёт дольше. Об этом ниже.
register и boot: почему их путают и что от этого ломается
У провайдера два метода, и порядок их вызова строгий. Сначала Laravel вызывает register у всех провайдеров, потом boot у всех. Отсюда правило:
- register — только привязки в контейнер. Ничего больше. Нельзя обращаться к другим сервисам: они могут быть ещё не зарегистрированы.
- boot — всё остальное: регистрация роутов, вью-композеров, слушателей событий, макросов. Здесь весь контейнер уже собран, любой сервис доступен.
Частая ошибка — вызвать в register что-то, что зависит от другого провайдера. Например, обратиться к настройкам пакета, чей провайдер грузится позже. Получите либо null, либо исключение о неразрешённой привязке — и оно плавающее, потому что зависит от порядка провайдеров в конфиге.
Контекстная привязка: когда одному интерфейсу нужны разные реализации
Реальный случай: ReportService и BackupService оба принимают Filesystem, но первому нужен локальный диск, второму — S3. Через обычный bind так не сделать — одна привязка на тип. Для этого есть контекстная привязка:
<?php
use App\Services\ReportService;
use App\Services\BackupService;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
// В методе register провайдера
$this->app->when(ReportService::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('local'));
$this->app->when(BackupService::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
Без этого приходится плодить интерфейсы-обёртки или тащить диск через конструктор вручную, теряя автоматическое разрешение. Контекстная привязка решает задачу на уровне контейнера.
Deferred-провайдеры: не грузить то, что не нужно
Каждый провайдер в списке register вызывается на каждом запросе. Если провайдер регистрирует тяжёлый клиент, который нужен только на паре роутов, вы платите за его инициализацию всегда. Отложенный провайдер решает это: контейнер не трогает его, пока кто-то не попросит связанный тип.
<?php
namespace App\Providers;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use App\Services\ExpensiveApiClient;
class ExpensiveApiServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(ExpensiveApiClient::class, function ($app) {
return new ExpensiveApiClient(config('services.expensive.token'));
});
}
// Контейнер грузит провайдер только когда просят эти типы
public function provides(): array
{
return [ExpensiveApiClient::class];
}
}
Важное ограничение: отложенный провайдер не может иметь метод boot с побочными эффектами вроде регистрации роутов — до вызова он просто не выполнится. Массив в provides() должен точно совпадать с тем, что вы привязали в register, иначе контейнер не найдёт связь и не загрузит провайдер, когда объект понадобится.
Где это ломается на живом проекте
Три ошибки, которые проходят локально и всплывают в проде:
- Singleton с состоянием под Octane. На PHP-FPM singleton пересоздаётся каждый запрос, и утечка состояния незаметна. Под Laravel Octane приложение держится в памяти: singleton, накопивший данные первого пользователя, отдаст их второму. Проверяйте: если в singleton хранится что-то, привязанное к текущему запросу или пользователю — это бомба под Octane. Такой объект должен быть
bind, а неsingleton, либо сбрасываться в middleware. - Обращение к сервису в register. Работает случайно, пока нужный провайдер грузится раньше. Добавили пакет — порядок сдвинулся, привязка не разрешилась. Все обращения к другим сервисам — в
boot. - Замыкание, захватившее контейнер целиком. Внутри фабрики берите зависимости из аргумента
$app, а не из внешней области видимости. Захваченный поuseобъект зафиксируется на момент регистрации и не обновится, если привязка ниже переопределена.
Как проверить, что и когда создаётся
Чтобы увидеть, действительно ли singleton один, а bind — новый, поставьте разрешение под наблюдение. Контейнер даёт хук resolving, срабатывающий при каждом создании объекта данного типа:
<?php
// В методе boot провайдера — для отладки
$this->app->resolving(\App\Contracts\PaymentGateway::class, function ($object, $app) {
logger()->debug('PaymentGateway создан', ['hash' => spl_object_id($object)]);
});
Если в логах за один запрос один и тот же spl_object_id — привязка ведёт себя как singleton. Разные значения при каждом обращении — это bind. Так вы отличите ожидаемое поведение от случайного до того, как оно проявится в проде.
Что делать дальше
Пройдите по существующим провайдерам и разметьте привязки: что реально должно быть одним объектом на запрос (singleton), а что несёт состояние и обязано быть новым (bind). Если проект переезжает на Octane — это обязательный аудит, начните с него. Тяжёлые редко используемые сервисы вынесите в DeferrableProvider и замерьте время бутстрапа приложения до и после через профилировщик или простой лог времени в middleware. Документацию по контейнеру держите под рукой: контекстная привязка и теги — отдельные темы, здесь мы их только обозначили.
Универсальный ассистент: Обычный чат: тексты, идеи, разбор, перевод, расчёты.
Спросить за 8 ₽Источники: Laravel Documentation — Service Container, Laravel Documentation — Service Providers
Частые вопросы
Чем bind отличается от singleton простыми словами?
У меня Laravel не 11, а 10 или 9 — код подойдёт?
Почему мой singleton отдаёт чужие данные в проде, а локально всё нормально?
Можно ли регистрировать роуты или события в методе register?
Что будет, если массив в provides() не совпадёт с реальными привязками?
Как понять, действительно ли объект создаётся один раз?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.