Сервис-контейнер Laravel изнутри: как работают биндинги и резолвинг
Что происходит внутри app()->make() и почему singleton иногда возвращает разные объекты, а contextual binding молча ломается. Разбираем механику контейнера Laravel 11.x на уровне вызовов.

Вы пишете $service = app(ReportService::class), и Laravel сам собирает объект со всеми зависимостями. Пока зависимость одна — это магия, которая просто работает. Как только в конструкторе появляется интерфейс, скалярный параметр или условная реализация, начинаются вопросы: почему singleton вдруг вернул два разных экземпляра, почему contextual binding не сработал, откуда BindingResolutionException на классе, который вы даже не биндили. Разбираем, что контейнер делает под капотом при резолвинге — на Laravel 11.x, но механика не менялась начиная с 8.x.
Речь только про механику биндингов и разрешения зависимостей. Service providers, их порядок загрузки и deferred providers — отдельная тема, здесь не трогаем.
Что контейнер хранит на самом деле
Контейнер (класс Illuminate\Container\Container, к которому и обращается фасад App и хелпер app()) — это не хранилище объектов. Это хранилище инструкций, как объект создать. Основные внутренние массивы:
$bindings— карта «абстракция → замыкание-фабрика плюс флаг shared». Сюда попадает всё, что вы регистрируете черезbind()иsingleton().$instances— уже готовые объекты. Заполняется приinstance()и при первом резолвинге singleton.$aliases— псевдонимы, чтобыapp('cache')вело к нужному классу.$contextual— вложенная карта для contextual binding: «когда собираешь класс A, для зависимости B подставь C».
Ключевое: bind() и singleton() ничего не создают. Они только кладут замыкание в $bindings. Объект появится, когда кто-то попросит его через make().
Контейнер лениво собирает объекты. Регистрация биндинга бесплатна — платите вы только в момент резолвинга. Поэтому сотни биндингов в провайдерах не замедляют старт: неиспользованные так и остаются замыканиями.
Как проходит резолвинг: путь одного make()
Когда вы вызываете app()->make(ReportService::class), внутри срабатывает resolve(). Упрощённый порядок шагов:
- Абстракция прогоняется через
getAlias()— если это псевдоним, он раскрывается в реальный класс. - Проверяется, нужен ли contextual binding для текущего места сборки (об этом ниже).
- Если абстракция есть в
$instancesи контекста нет — возвращается готовый объект, без всякой сборки. - Определяется «concrete» — что именно строить. Если биндинг есть, берётся его замыкание. Если биндинга нет, concrete равен самой абстракции.
- Если concrete можно собрать (это замыкание или имя класса, совпадающее с абстракцией) — вызывается
build(). Иначеmake()вызывается рекурсивно. - Применяются extenders (то, что зарегистрировано через
extend()). - Если биндинг помечен как shared — результат кладётся в
$instances, чтобы в следующий раз вернуть тот же объект.
Самое интересное — шаг сборки без биндинга. Если вы никогда не регистрировали ReportService, контейнер всё равно его создаст. Этот механизм называется автоматическим разрешением зависимостей.
build() и рефлексия
Метод build() берёт имя класса и через ReflectionClass смотрит его конструктор. Для каждого параметра конструктора:
- если параметр — класс или интерфейс, контейнер рекурсивно вызывает
make()для него; - если параметр скалярный (
int,string, массив) — контейнер ищет его в contextual binding или значении по умолчанию; - если ни того, ни другого нет — летит
BindingResolutionException.
// Laravel 11.x
class ReportService
{
public function __construct(
private ReportRepository $repository, // соберётся автоматически
private LoggerInterface $logger, // интерфейс — нужен биндинг
private int $cacheTtl // скаляр — нужен contextual или дефолт
) {}
}
Здесь ReportRepository — конкретный класс, контейнер соберёт его сам. А вот LoggerInterface контейнер построить не может: у интерфейса нет конструктора. Если биндинга нет, вы увидите:
Illuminate\Contracts\Container\BindingResolutionException:
Target [App\Contracts\LoggerInterface] is not instantiable
while building [App\Services\ReportService].
Сообщение прямо называет цепочку сборки: интерфейс не создаётся, потому что вы его собирали внутри ReportService. Лечится биндингом интерфейса на реализацию.
bind, singleton, instance: в чём разница механически
Три способа регистрации отличаются флагом shared и тем, что именно кладётся в контейнер.
| Метод | Что кладётся | Shared | Когда создаётся объект |
|---|---|---|---|
bind() | Замыкание-фабрика | Нет | При каждом make() — новый экземпляр |
singleton() | Замыкание-фабрика | Да | Один раз, при первом make(), дальше кэш |
scoped() | Замыкание-фабрика | Да, но сбрасывается | Один раз за жизненный цикл запроса/джобы |
instance() | Готовый объект | Да | Уже создан — контейнер просто хранит |
scoped() — тот же singleton, но контейнер помнит его в отдельном списке и сбрасывает при forgetScopedInstances(). В Octane это критично: обычный singleton живёт между запросами и утекает, scoped обнуляется на каждом запросе.
Почему singleton вернул два разных объекта
Частая ловушка. Вы сделали singleton(Foo::class), но получаете разные экземпляры. Причины по убыванию частоты:
- Кто-то биндит тот же класс через
bind()в другом провайдере позже — последняя регистрация побеждает и делает биндинг не-shared. - Вы резолвите через интерфейс в одном месте и через конкретный класс в другом. Singleton на интерфейсе и singleton на классе — два разных ключа в
$bindings. - Между запросами в Octane singleton живёт, но
make(Foo::class, ['x' => 1])с параметрами всегда собирает заново —make()с$parametersигнорирует кэш instances.
Contextual binding: почему молча не срабатывает
Contextual binding решает задачу «двум классам нужна одна и та же зависимость, но разные её реализации»:
// В service provider, Laravel 11.x
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('local');
});
Работает это через массив $contextual, и вот здесь прячется главный подводный камень: contextual binding применяется только при сборке через build(), когда контейнер сам разрешает конструктор. Если объект уже лежит в $instances (например, был зарезолвлен как singleton раньше без контекста) — контекст проигнорируется, потому что до build() дело не дойдёт.
Второй частый промах — контекст указан на интерфейсе, а резолвите вы конкретный класс. Ключом в $contextual служит имя того класса, который контейнер реально собирает, а не то, что вы написали в when(), если между ними алиас или другой биндинг.
Где всё ломается
- Циклическая зависимость. A требует B, B требует A. Контейнер отслеживает стек сборки в
$buildStackи падает сCircularDependencyException. Решается ленивым резолвингом одной из сторон или пересмотром дизайна. - Union-типы и nullable в конструкторе. Если параметр объявлен как
A|B, рефлексия не знает, что собирать, и контейнер бросает исключение. Разбивайте на явный биндинг. - Скаляр без дефолта и без контекста.
public function __construct(string $apiUrl)без значения по умолчанию — гарантированныйBindingResolutionException. Задайте дефолт или contextual->needs('$apiUrl')->give(...). - Утечки singleton в долгоживущем процессе. В Octane и очередях singleton держит состояние между запросами. Если внутри он копит данные (кэш, коллекцию, соединение) — используйте
scoped()или сбрасывайте вручную.
Как посмотреть, что происходит
Чтобы не гадать, добавьте временный хук на резолвинг конкретного типа:
// Laravel 11.x, в AppServiceProvider::boot()
$this->app->resolving(ReportService::class, function ($service, $app) {
logger()->debug('ReportService зарезолвлен', [
'hash' => spl_object_id($service), // один и тот же id = один объект
]);
});
Если spl_object_id в логах разный при каждом обращении — ваш «singleton» на самом деле собирается заново, и пора искать перекрывающий bind(). Ещё один способ увидеть картину целиком — app()->getBindings() в тинкере: вернёт весь массив зарегистрированных абстракций с флагом shared.
Что делать дальше
Проверьте свои провайдеры на два симптома: один и тот же класс, забинженный дважды в разных провайдерах, и singleton, который держит изменяемое состояние. Первое ищется по getBindings(), второе выстрелит только под нагрузкой в Octane или в длинных воркерах. Если переходите на Octane — пройдитесь по всем singleton() и решите, какие из них должны стать scoped(). Дальше стоит заглянуть в исходник Illuminate\Container\Container: методы resolve(), build() и resolveClass() читаются за полчаса и снимают большинство оставшихся вопросов о том, почему объект собрался именно так.
Автоэксперт / механик: Расшифровка OBD2-кодов, выбор б/у авто, диагностика по симптомам.
Спросить за 12 ₽Источники: Laravel Documentation — Service Container, laravel/framework — Illuminate\Container\Container (GitHub)
Частые вопросы
У меня Laravel 8, а не 11 — механика биндингов та же?
Почему я получаю BindingResolutionException на классе, который вообще не биндил?
Contextual binding через when()->needs()->give() не срабатывает, что проверить?
Чем scoped() отличается от singleton() и когда он нужен?
make() с параметрами возвращает новый объект даже для singleton — это баг?
Как быстро увидеть все зарегистрированные биндинги?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.