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 за вас:
phpapp(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 как зависимость | Явные зависимости в конструкторе |
Подводный камень
- Singleton живёт дольше, чем кажется. Под Octane, в очередях и воркерах singleton хранится в памяти процесса: Mutable-свойство, заполненное в одном запросе, достанется следующему. Правило: singleton — только без изменяемого состояния; нужное состояние — через
bind, Redis или базу. - Интерфейс без привязки падает с загадочной ошибкой.
Unresolvable dependencyилиTarget [PaymentGateway] is not instantiable— значит, просят интерфейс, а реализации в контейнере нет. Первым делом проверьтеAppServiceProvider. - Строить сервис руками вместо контейнера.
$service = new OrderService(new OrderRepository(new Connection(...)))— вы вручную повторили работу контейнера и потеряли все привязки: интерфейс резолвиться перестал, а singleton превратился в «новый объект каждый раз». Просите зависимости снаружи, а не собирайте внутри. $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);
Три вывода
- Контейнер — это «как построить объект»: concrete-классы он строит сам через рефлексию, для интерфейсов нужны привязки;
newвнутри логики этот механизм ломает. bind— новый объект на каждый запрос,singleton— один на процесс,instance— готовый объект; singleton с mutable-состоянием под Octane и в очередях течёт между запросами.- Фасады — обёртка над
app():Cache::getиapp(CacheManager::class)->get— один и тот же объект, поэтому подмена в контейнере видна и через фасад.
Вопрос-ловушка
Спрашивают так: «Чем bind отличается от singleton?» Простой ответ «singleton создаётся один раз» — половина дела. Сильный — добавить про последствия: singleton живёт в памяти процесса, поэтому под Octane или в очередях его mutable-состояние переживает запрос — и это источник «невоспроизводимых» багов. Хитрый поворот: «Как подменить реализацию в тесте, если сервис делает new внутри?» Ответ — никак: именно поэтому зависимости вводят через конструктор, а new в глубине логики — антипаттерн. И ещё: «Cache::get() — это статический вызов? Почему его можно замокать?» Ожидаемый ответ — фасад это не настоящая статика, под капотом резолвится объект из контейнера; замокать можно, потому что подменяем объект в контейнере, а фасад лишь ссылается на него.