Service container и DI в Laravel: кто и откуда берёт ваши зависимости

Service container — это «шкаф» с готовыми объектами, из которого Laravel достаёт нужный класс, когда вы просите его в конструкторе или аргументе метода. Dependency injection (DI) — способ работать с зависимостями: не создавать их через new внутри кода, а получать снаружи уже собранными. Понимать связку нужно, потому что от неё зависят всё: фасады, route model binding, auth()->user(), тесты с моками — это всё один и тот же механизм под разными именами. А на практике именно здесь чаще всего стреляет «на локалке работает» — когда разработчик не понимает, откуда Laravel берёт класс и что произойдёт, если подменить его привязку.


Зачем это нужно

Боль из практики: в проекте доставки сервис расчёта стоимости строил зависимость сам:

php
// ПЛОХО: сервис сам создаёт свою зависимость
class DeliveryCostService
{
    public function calculate(Order $order): float
    {
        $rates = new CbrRatesClient();          // курс валют — жёстко вшит
        $usd = $rates->current();
        return $order->weight * config('delivery.rate') * $usd;
    }
}

Работало. Пока не понадобились тесты: CbrRatesClient внутри лезет на внешний API — в тесте сеть недоступна и поведение непредсказуемо. Подменить класс нельзя: он вшит оператором new, у сервиса нет точки, через которую можно передать другую реализацию. Пришлось переписывать:

php
// ХОРОШО: зависимость приходит снаружи — её можно подменить
class DeliveryCostService
{
    public function __construct(
        private RatesClient $rates,      // контракт, а не конкретный класс
    ) {}

    public function calculate(Order $order): float
    {
        return $order->weight * config('delivery.rate') * $this->rates->current();
    }
}

// А в тесте — подменить одной строкой:
$this->instance(RatesClient::class, new FakeRatesClient(usd: 90.0));

Разница не в стиле: в первом варианте класс закрыт от подмены, во втором — открыт. И закрывает его именно new.

Как это работает

Контейнер — реестр «как построить объект». В 90% случаев вы его не видите: Laravel сам разрешает зависимости через рефлексию конструкторов.

php
// Ничего не регистрируем — Laravel сам построит цепочку:
class OrderService
{
    public function __construct(
        private OrderRepository $orders,   // concrete-класс — резолвится без настроек
        private PaymentGateway $gateway,   // интерфейс — нужна привязка (ниже)
    ) {}
}

// Контроллер тоже получает зависимости из контейнера:
class CheckoutController extends Controller
{
    public function __invoke(OrderService $service)  // сконструирован контейнером
    {
        // ...
    }
}

Автоматическое разрешение работает для concrete-классов: контейнер видит типы в конструкторе, рекурсивно строит их сам. Интерфейс сам построить нельзя — неизвестна реализация, поэтому для интерфейсов нужна привязка.

Привязки — инструкции «что выдать, когда просят X»:

php
// AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// bind: каждый раз НОВЫЙ объект

$this->app->singleton(RatesCache::class, RatesCache::class);
// singleton: один объект на всё приложение, строится один раз и переиспользуется

$this->app->instance(ConfiguredClient::class, $client);
// instance: готовый объект — кладём, что построили сами (например, в тесте)
php
// Контекстные привязки: разным классам — разные реализации одного интерфейса
$this->app->when(InvoiceMailer::class)
    ->needs(PaymentGateway::class)
    ->give(StripeGateway::class);

$this->app->when(RefundProcessor::class)
    ->needs(PaymentGateway::class)
    ->give(PaypalGateway::class);

Получить из контейнера можно напрямую, но чаще это делает Laravel за вас:

php
app(PaymentGateway::class);        // вручную — редко нужно
$this->app->make(PaymentGateway::class);

// Инъекция: контроллер, middleware, job, listener, метод маршрута — везде контейнер активен

Фасады — это контейнер. Cache::get('key') на самом деле означает app(CacheManager::class)->get('key'): фасад — тонкая обёртка над app(). Именно поэтому Cache::fake() в тестах работает: подменяется объект в контейнере, а фасад просто смотрит туда же.

Как это выглядит в Laravel

Привязки обычно живут в app/Providers/AppServiceProvider.php. В Laravel 11+ регистрации делаются проще — там же автоматически сканируются «обучаемые» классы:

php
// Laravel 11+: автоматические привязки по соглашению
// интерфейс PaymentGateway → реализация PaymentGateway? нет:
// сканируется app/ и привязывается автоматически, если реализация одна
interface RatesClient {}

final class CbrRatesClient implements RatesClient {}
// Одна реализация интерфейса в app/ → Laravel привяжет сам, без кода в провайдере

Но явная привязка надёжнее: автоматика скрывает источник правды, а при появлении второй реализации молча начнёт спорить.

Singleton и состояние — отдельная осторожность. Под Octane и в очередях объекты живут между запросами:

php
$this->app->singleton(UserPreferences::class);
// Плохо, если внутри есть mutable-поле: оно переживёт запрос
// и «перетечёт» в следующий запрос того же воркера

Синглтоны в контейнере держите только для stateless-сервисов: HTTP-клиенты, менеджеры соединений. Состояние — в Redis, базу или запрос.

Плохо / Хорошо

ПлохоХорошо
new CbrRatesClient() внутри сервисаИнъекция через конструктор
Привязка интерфейса «где-то в ServiceProvider №7»Все привязки в одном месте, в AppServiceProvider
singleton для класса с mutable-состояниемbind для stateful, singleton — для stateless
app() внутри глубоких слоёв логикиТолько в точках сборки: провайдеры, команды, тесты
Интерфейс с одной реализацией «на всякий случай»Интерфейс, когда есть вторая реализация или тестовый мок
Сервис получает Container как зависимостьЯвные зависимости в конструкторе

Подводный камень

  1. Singleton живёт дольше, чем кажется. Под Octane, в очередях и воркерах singleton хранится в памяти процесса: Mutable-свойство, заполненное в одном запросе, достанется следующему. Правило: singleton — только без изменяемого состояния; нужное состояние — через bind, Redis или базу.
  2. Интерфейс без привязки падает с загадочной ошибкой. Unresolvable dependency или Target [PaymentGateway] is not instantiable — значит, просят интерфейс, а реализации в контейнере нет. Первым делом проверьте AppServiceProvider.
  3. Строить сервис руками вместо контейнера. $service = new OrderService(new OrderRepository(new Connection(...))) — вы вручную повторили работу контейнера и потеряли все привязки: интерфейс резолвиться перестал, а singleton превратился в «новый объект каждый раз». Просите зависимости снаружи, а не собирайте внутри.
  4. $this->app->instance() в тестах не «сам чистится». Подменённый instance переопределяется тестами в этом же процессе — соседние тесты увидят подмену. В тестах используйте mock() / bind() с автоочисткой или запоминайте, что подменяли.

Мини-шпаргалка

php
// AppServiceProvider
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(RatesCache::class);

// Подмена в тесте
$this->instance(RatesClient::class, new FakeRatesClient(usd: 90.0));
$this->mock(PaymentGateway::class, fn ($m) => $m->shouldReceive('charge')->once());

// Разрешение вручную (точки сборки)
$service = app(OrderService::class);

// Контекстные привязки: классу — своя реализация
$this->app->when(RefundProcessor::class)
    ->needs(PaymentGateway::class)
    ->give(PaypalGateway::class);

Три вывода

  1. Контейнер — это «как построить объект»: concrete-классы он строит сам через рефлексию, для интерфейсов нужны привязки; new внутри логики этот механизм ломает.
  2. bind — новый объект на каждый запрос, singleton — один на процесс, instance — готовый объект; singleton с mutable-состоянием под Octane и в очередях течёт между запросами.
  3. Фасады — обёртка над app(): Cache::get и app(CacheManager::class)->get — один и тот же объект, поэтому подмена в контейнере видна и через фасад.

Вопрос-ловушка

Спрашивают так: «Чем bind отличается от singleton?» Простой ответ «singleton создаётся один раз» — половина дела. Сильный — добавить про последствия: singleton живёт в памяти процесса, поэтому под Octane или в очередях его mutable-состояние переживает запрос — и это источник «невоспроизводимых» багов. Хитрый поворот: «Как подменить реализацию в тесте, если сервис делает new внутри?» Ответ — никак: именно поэтому зависимости вводят через конструктор, а new в глубине логики — антипаттерн. И ещё: «Cache::get() — это статический вызов? Почему его можно замокать?» Ожидаемый ответ — фасад это не настоящая статика, под капотом резолвится объект из контейнера; замокать можно, потому что подменяем объект в контейнере, а фасад лишь ссылается на него.