Laravel: Dependency Injection — перестаём создавать объекты руками

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

Знакомая картина: внутри сервиса нужна отправка письма, лог или платёжный шлюз — и мы честно пишем new MailService() прямо в методе. Работает. Пока не нужно заменить реализацию, протестировать логику без реальных запросов или переиспользовать класс в другом месте. Тогда выясняется: класс намертво привязан к тому, что сам и создал.

Dependency Injection (DI, внедрение зависимостей) — это когда нужные объекты класс не создаёт сам, а получает снаружи. В Laravel «снаружи» — это сервис-контейнер: он смотрит на типы в конструкторе и подставляет готовые экземпляры.

Аналогия: бариста не выращивает кофейные зёрна — ему их приносят. Сменился поставщик — бариста не меняется.

Классика без DI

php
class OrderService
{
    public function placeOrder(array $data): void
    {
        $payment = new PaymentService();   // создаём сами
        $payment->pay($data['amount']);

        $repo = new OrderRepository();     // и это тоже
        $repo->save($data);
    }
}

Три проблемы:

  • жёсткая привязка: захотели Stripe вместо внутреннего сервиса — правим OrderService;
  • тесты: чтобы проверить логику заказа, придётся дёргать реальную платёжку и базу;
  • неявные зависимости: по сигнатуре класса не видно, что ему нужно для работы.

С DI через конструктор

php
class OrderService
{
    public function __construct(
        private PaymentInterface $payment,
        private OrderRepository $orders,
    ) {}

    public function placeOrder(array $data): void
    {
        $this->payment->pay($data['amount']);
        $this->orders->save($data);
    }
}

Теперь зависимости видны в конструкторе, а кто именно их создаёт — решает контейнер. Регистрируем привязку интерфейса к реализации:

php
// AppServiceProvider::register()
$this->app->bind(PaymentInterface::class, StripePaymentService::class);

Дальше магия Laravel: в контроллере просто объявляем тип — и контейнер собирает всю цепочку сам.

php
class OrderController extends Controller
{
    public function __construct(private OrderService $orderService) {}
}

При создании контроллера Laravel создаст OrderService, а для него — StripePaymentService и репозиторий. Вложенность любой глубины, руками ничего передавать не нужно. Тот самый случай, когда вылезает ArgumentCountError — это как раз когда конструктор ждёт зависимость, а объект создают вручную без аргументов.

Подмена для тестов

Главный выигрыш DI — подменяемость. В тесте регистрируем фейк вместо реального шлюза:

php
$this->app->bind(PaymentInterface::class, FakePaymentService::class);

Логика заказа проверяется без единого реального запроса к платёжной системе.

Подводный камень: Service Locator и статика

DI ломают двумя способами, и оба выглядят безобидно:

php
// ПЛОХО: создание внутри метода
public function doSomething(): void
{
    $client = new MailClient(); // зависимость спрятана, тест не подменить
}

// ПЛОХО: app() внутри класса — это Service Locator, не DI
public function doSomething(): void
{
    app(MailClient::class)->send(...); // зависимость видна только внутри метода
}

Правило простое: зависимости — в конструктор (или аргументы метода), new для сервисов — нигде. Ещё две грабли: не внедряйте через контейнер примитивы (строки, числа — их передавайте аргументами) и следите за циклическими зависимостями (A требует B, B требует A — контейнер не соберёт такую конструкцию, надо выносить общую логику в третий сервис).

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

php
// Регистрация
$this->app->bind(Interface::class, Implementation::class);   // новый экземпляр на каждый запрос
$this->app->singleton(Interface::class, Implementation::class); // один на всё приложение

// Внедрение
public function __construct(private PaymentInterface $payment) {}  // конструктор
public function handle(PaymentInterface $payment) {}               // метод (job, middleware)

// Подмена в тесте
$this->app->bind(PaymentInterface::class, FakePayment::class);

И три вывода:

  1. DI — это передача зависимостей снаружи через конструктор, а не создание их через new внутри класса.
  2. Сервис-контейнер Laravel сам читает типы и собирает цепочку объектов — от контроллера до самого нижнего сервиса.
  3. Главная выгода — тестируемость и гибкость: реализацию подменяют одной строкой bind, не трогая бизнес-логику.