Коротко
- Внедрение зависимостей — это механика: класс получает свои зависимости, а не создаёт их. Инверсия зависимостей — это проектное решение: зависеть от абстракции, а не от конкретного класса. Это не одно и то же.
- Контейнер Laravel делает DI за вас автоматически через type-hint в конструкторе. Биндинг вы пишете только когда инвертируете зависимость на интерфейс.
- Большинству классов интерфейс не нужен. Добавляйте его на границе, которая меняется или которой нужен тестовый шов — платежи, почта, хранилище, внешние API, — а не рефлекторно каждому сервису.
- Антипаттерн, сводящий всё на нет: доставать зависимости через
app()в глубине методов вместо инъекции. Это service locator, и он прячет ровно то, что вы пытались сделать явным.
Однажды мне достался Laravel-проект, где у каждого сервиса был интерфейс. UserServiceInterface, InvoiceServiceInterface, ReportServiceInterface — у каждого ровно одна реализация, каждый забинжен один-к-одному в сервис-провайдере на четыреста строк. Команда считала, что «следует SOLID». На деле они построили код, где переход к определению метода всегда приводил на интерфейс, и приходилось искать биндинг, чтобы узнать, что реально выполняется. Абстракция добавляла косвенность и не убирала ничего.
Тот проект научил меня различию, о котором эта статья. Внедрение и инверсию зависимостей используют как синонимы, и именно это размытие приводит к интерфейсу на каждый класс. Они решают разные задачи, и понимание того, что вам реально нужно, и держит Laravel-код чистым по мере роста.
DI и DIP — это не одно и то же
Внедрение зависимостей означает, что класс получает то, от чего зависит, а не конструирует это сам. В Laravel это почти всегда инъекция через конструктор, и зависимость для вас строит контейнер:
class RegisterUser
{
public function __construct(
private Mailer $mailer,
) {}
}Это уже лучше, чем new Mailer(...) внутри метода — класс больше не владеет связыванием. Но заметьте: он по-прежнему зависит от конкретного Mailer. Вы внедрили зависимость, не инвертировав её.
Инверсия зависимостей — второй, отдельный шаг: сделать так, чтобы высокоуровневый класс зависел от абстракции, которой владеет он сам, и тогда конкретная реализация становится деталью, которую можно подменить.
interface Mailer
{
public function send(Message $message): void;
}
class RegisterUser
{
public function __construct(
private Mailer $mailer, // now an interface
) {}
}Теперь класс формулирует требование («что-то, что умеет отправить сообщение»), а не называет вендора. SMTP, Mailgun, SES или фейк в тестах — всё это его удовлетворяет. Это и есть инверсия: направление зависимости перевернулось с «высокоуровневый код зависит от низкоуровневой детали» на «оба зависят от контракта».
Как контейнер Laravel это на самом деле резолвит
Для обычного DI вы не пишете вообще никакой конфигурации. Когда контейнер строит RegisterUser, он читает type-hint конструктора и рекурсивно резолвит каждую зависимость. Конкретный класс со своими разрешимыми зависимостями просто работает — поэтому большинству контроллеров и джобов Laravel биндинг не нужен.
Инъекция в метод работает так же. Поставьте type-hint зависимости в экшене контроллера, и контейнер передаст её на каждый запрос:
public function store(Request $request, Mailer $mailer)
{
// $mailer resolved by the container for this call
}В тот момент, когда вы зависите от интерфейса, контейнер уже не может угадать. У интерфейса нет конструктора, который можно построить. Вы должны сказать ему, какую реализацию использовать, и это единственная причина существования биндингов:
// In a service provider's register() method
$this->app->bind(Mailer::class, MailgunMailer::class);Теперь везде, где Mailer указан type-hint’ом, контейнер отдаёт MailgunMailer. Перевести всё приложение на SES — это одна строка здесь, а не поиск-замена по всему коду. Эта единая точка изменения — и есть вся выгода инверсии.
singleton() делит состояние на весь запрос
bind() строит новый экземпляр каждый раз; singleton() строит один раз и переиспользует. Синглтон, хранящий изменяемое состояние — закэшированного пользователя, накапливающийся массив, — протекает этим состоянием во всё, что резолвится в том же запросе. Используйте singleton() для безсостоятельных зависимостей и объектов вроде соединений, а не для всего, что мутирует на каждый вызов.
Контекстный биндинг: когда двум потребителям нужны разные реализации
Случай, который оправдывает интерфейс яснее всего, — когда один и тот же контракт должен резолвиться по-разному в зависимости от того, кто спрашивает. Laravel решает это контекстным биндингом, и именно эта фича делает инверсию окупаемой:
$this->app->when(PublicUploadController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
$this->app->when(PrivateReportController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('local'));Оба контроллера просят одну и ту же абстракцию Filesystem; контейнер выдаёт каждому нужный диск. Без интерфейса варьировать нечего. Это и есть разница между интерфейсом, который оправдывает своё место, и тем, что просто добавляет лишний переход.
Когда интерфейс оправдывает место — а когда это шум
Код из вступления пошёл не туда, считая «добавить интерфейс» всегда хорошим. Это компромисс: интерфейс покупает гибкость и тестовый шов, а стоит — слоя косвенности и биндинга, который надо поддерживать. Тратьте его там, где гибкость реальна.
| Ситуация | Интерфейс? | Почему |
|---|---|---|
| Внешняя граница (платежи, почта, хранилище, SMS) | Да | Вендор сменится, и вам нужен фейк в тестах |
| Несколько реальных реализаций уже сегодня | Да | Это буквально то, для чего абстракция |
| Замена, для которой можно назвать конкретную причину | Да | «В следующем квартале переедем с SES на Postmark» |
| Маленький самодостаточный сервис с одной реализацией | Нет | Внедряйте конкретный класс; добавьте интерфейс в день, когда нужна вторая |
| Объект-значение или чистый доменный расчёт | Нет | Инвертировать нечего; у него нет инфраструктурной зависимости |
Честный дефолт в Laravel: внедряйте конкретные классы и тянитесь за интерфейсом, когда класс сидит на границе между вашим доменом и внешним миром. «Вдруг когда-нибудь понадобится» — не причина; «за этим живёт платёжный провайдер» — причина.
Тестирование через границу
Вот где инверсия окупается напрямую. Поскольку потребители зависят от контракта, тест может подставить фейк, не трогая тестируемый код. Контейнер делает подмену одним вызовом:
public function test_registration_sends_a_welcome_message(): void
{
$mailer = new FakeMailer();
$this->app->instance(Mailer::class, $mailer);
$this->post('/register', [...]);
$this->assertCount(1, $mailer->sent);
}Никакого SMTP, никакой сети, никакой гимнастики с мок-фреймворком — настоящий RegisterUser работает с фейковой зависимостью, потому что всегда зависел только от интерфейса. Это и есть тестируемость, которую приписывают DI, но которая на деле приходит от инверсии: конкретную зависимость так чисто не подменить.
Ошибки, которые я вижу в реальном коде на Laravel
Интерфейс на каждый класс
FooServiceInterface с одной реализацией добавляет косвенность и не убирает ничего. Добавляйте интерфейс, когда вторая реализация или тестовый шов реальны.
Service locator вместо инъекции
Вызов app(Mailer::class) в глубине метода прячет зависимость от конструктора. Лучше поставьте type-hint, чтобы класс заявлял, что ему нужно.
Протечка фреймворк-типов через контракт
Интерфейс, чьи методы принимают модель Eloquent или Request, ничего не абстрагирует. Передавайте через границу простые данные или объекты-значения.
Божественные интерфейсы «Manager»
ServiceManagerInterface с пятнадцатью несвязанными методами — это не контракт. Разбейте его по реальной возможности, нужной каждому потребителю.
Состоятельные синглтоны
Биндинг изменяемого объекта как singleton() протекает состоянием через весь запрос. Берегите синглтоны для безсостоятельных сервисов или объектов-соединений.
Биндинг не в том месте
Резолв сервисов внутри register() выполняется до загрузки всех провайдеров. Биндинги — в register(), резолв — в boot().
FAQ
- В чём разница между внедрением и инверсией зависимостей?
- Внедрение зависимостей — это техника: класс получает зависимости извне, а не создаёт их. Инверсия зависимостей — это принцип проектирования: высокоуровневый код зависит от абстракции, а не от конкретного класса. Можно внедрить конкретный класс (DI без инверсии); инверсия — это дополнительный шаг зависимости от интерфейса.
- Нужен ли интерфейс каждому сервису в Laravel?
- Нет. По умолчанию внедряйте конкретные классы. Вводите интерфейс, когда зависимость сидит на внешней границе (платежи, почта, хранилище), когда у вас реально несколько реализаций или когда нужен чистый тестовый шов. Интерфейс с единственной реализацией обычно лишь добавляет косвенность.
- Почему Laravel резолвит некоторые классы без всякого биндинга?
- Контейнер читает type-hint конструктора и строит конкретные классы автоматически, рекурсивно резолвя их зависимости. Биндинг нужен только когда вы ставите type-hint на интерфейс, ведь у интерфейса нет конструктора, который контейнер мог бы построить.
- Что такое антипаттерн service locator?
- Вызов
app()илиresolve()для получения зависимостей внутри метода вместо инъекции через конструктор. Он прячет, от чего зависит класс, и усложняет тестирование — это противоположность тому, ради чего нужно внедрение зависимостей. - Когда использовать bind(), а когда singleton()?
- Используйте
bind(), когда каждый потребитель должен получить свежий экземпляр — это безопасный дефолт. Используйтеsingleton()только для безсостоятельных зависимостей или объектов-соединений, которые хотите переиспользовать на весь запрос. Синглтон с изменяемым состоянием протекает им во всё, что резолвится после него.
Похожие статьи
- Подписки в Laravel с Cashier и Stripe: что ломается в реальных потоках — применяет ровно этот паттерн границы, чтобы держать код Stripe вне контроллеров.
- Важность безопасности в веб-разработке — про то, почему единственную, чётко заданную границу для внешних интеграций проще аудировать и защищать.




