Провайдери - це список класів, який Laravel проходить двічі. Спершу Application::registerConfiguredProviders() бере провайдери з bootstrap/providers.php (у Laravel 11+ саме там, а не в config/app.php) плюс знайдені через package discovery у bootstrap/cache/packages.php і викликає в кожного register(). Коли зареєстровані всі, окремим проходом іде Application::boot() з викликом boot() у тому ж порядку. На цьому поділі тримається вся роль провайдера: у register() ви не знаєте, що вже лежить у контейнері, бо чужий провайдер може стояти нижче в списку, а в boot() знаєте напевно. Звідси правило, яке звучить догматично, поки не спіймаєш баг: у register() тільки привʼязки й mergeConfigFrom(), у boot() - маршрути, слухачі подій, Gate::define(), Blade-директиви, publishes(). Виклик make() усередині register() небезпечний і поза питанням порядку: він заморожує обʼєкт до того, як інший провайдер устигне зробити йому extend(), і передчасно піднімає відкладені провайдери.
Три способи привʼязки відрізняє одне: скільки живе результат. bind() зберігає рецепт і виконує його на кожне розвʼязання. singleton() - це той самий bind() з прапорцем shared: true: перший make() кладе обʼєкт у $instances, усі наступні дістають його звідти. Слово «singleton» тут оманливе, бо життя обмежене життям контейнера, а не процесу PHP: під FPM контейнер помирає разом із запитом, тому singleton там де-факто був request-scoped, і покоління розробників писало його не замислюючись. scoped() робить те саме, що singleton(), плюс записує abstract у масив $scopedInstances - саме цей масив вичищає forgetScopedInstances(). Поруч є instance() (покласти готовий обʼєкт), bindIf()/singletonIf() для пакетів, які не мають затирати вибір застосунку, і extend() для обгортання чужої реалізації.
Різниця між singleton і scoped стає видимою рівно тоді, коли контейнер переживає запит. Octane тримає один Application на воркер і між запитами прогонює слухача FlushTemporaryContainerInstances, який викликає forgetScopedInstances(); черговий воркер робить те саме через Worker::resetScope() перед кожною задачею. Тому scoped - це «один на запит або задачу», а singleton під Octane - «один на воркер, поки його не перезапустять». Сервіс, який тримає в полі Request, поточного користувача, locale чи тенанта, у першому випадку безпечний, у другому віддасть другому відвідувачу дані першого. Скидання не всесильне: воно прибирає обʼєкт із контейнера, але не рятує від захопленого замиканням $request, статичної властивості або вже розвʼязаної залежності всередині іншого singleton-а. Для таких випадків у config/octane.php є списки flush і warm.
Відкладений провайдер живе за домовленістю: його не чіпають, поки хтось не попросить звʼязку. Клас реалізує DeferrableProvider і повертає з provides() перелік звʼязок; ProviderRepository компілює bootstrap/cache/services.php, де eager-провайдери реєструються завжди, а deferred лежать картою «звʼязка → провайдер». Коли make() натрапляє на невідому звʼязку з цієї карти, викликається registerDeferredProvider(), який реєструє провайдер і одразу бутить, якщо застосунок уже завантажений. Маніфест перебудовується сам, щойно змінився список провайдерів, і зникає після optimize:clear. Дві пастки трапляються постійно. Перша: provides() мусить містити той самий рядок, за яким реально резолвлять - забіндили 'pdf.renderer', а повернули PdfRenderer::class, і привʼязка не зʼявиться ніколи. Друга: boot() відкладеного провайдера виконується непередбачувано пізно, тому маршрут або Event::listen() там тихо не працюють; для подій є окремий вихід - метод when(), який повертає імена подій, на які провайдер підпишуть ліниво.
Контекстна привʼязка закриває те, чого autowiring не може за визначенням. Скаляр без значення за замовчуванням дає BindingResolutionException: Unresolvable dependency, і лікується він через when(X::class)->needs('$dpi')->give(...) або ->giveConfig('pdf.dpi'). Один інтерфейс із двома реалізаціями для різних споживачів - той самий when()->needs()->give(), а для наборів однотипних сервісів є tag()/giveTagged(). Механіка тут неочевидна: при контекстному розвʼязанні контейнер навмисно ігнорує кеш $instances і не пише туди результат, інакше перший споживач зафіксував би свою реалізацію для всього застосунку. З Laravel 11 частину цих випадків забирають контекстні атрибути в сигнатурі (#[Config], #[Storage], #[CurrentUser]), а Laravel 12 додав #[Bind], #[Singleton] і #[Scoped] на інтерфейсі. Це менше коду, але вибір реалізації переїжджає в клас-споживач, і підмінити його одним рядком у тесті вже не вийде. Загальна межа лишається простою: провайдери потрібні інтерфейсам, скалярам і налаштуванню пакетів, а не кожному сервісу - конкретний клас із типізованим конструктором контейнер збере й без жодної реєстрації.
// bootstrap/providers.php (Laravel 11+) містить лише список класів провайдерів.
final class PdfServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
// Конфіг пакета треба злити до того, як хтось прочитає config('pdf.*').
$this->mergeConfigFrom(__DIR__.'/../../config/pdf.php', 'pdf');
// Новий обʼєкт на кожну інʼєкцію: рендерер накопичує стан документа.
$this->app->bind(PdfRenderer::class, fn (Application $app) => new PdfRenderer(
$app->make(BinaryLocator::class),
));
// Один на процес: усередині лише шлях до бінарника, даних запиту немає.
$this->app->singleton(BinaryLocator::class);
// Тримає locale й поточного тенанта, тому лише scoped:
// Octane скидає його через forgetScopedInstances() між запитами,
// а Worker::resetScope() - перед кожною задачею черги.
$this->app->scoped(RenderContext::class);
// Скалярний $dpi контейнер сам не розвʼяже: даємо контекстно.
$this->app->when(PdfRenderer::class)
->needs('$dpi')
->giveConfig('pdf.dpi', 150);
// Той самий інтерфейс, різні диски для різних споживачів.
$this->app->when(InvoiceExporter::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('invoices'));
}
public function boot(): void
{
// Спрацює лише тоді, коли хтось витягне звʼязку з provides().
// Тому Route:: і Event::listen() сюди класти не можна.
$this->publishes([__DIR__.'/../../config/pdf.php' => config_path('pdf.php')], 'pdf');
}
/** @return array<int, string> Без цього списку провайдер не завантажиться ніколи. */
public function provides(): array
{
return [PdfRenderer::class, BinaryLocator::class, RenderContext::class];
}
}
Тримайте дві осі й не змішуйте їх. Перша - час: `register()` до всього, `boot()` після всіх реєстрацій. Друга - життя обʼєкта: `bind` (щоразу новий), `singleton` (один на життя контейнера), `scoped` (один на запит або задачу). Далі одним реченням додайте, що `scoped` існує саме заради Octane й довгих воркерів, а `deferred` - це компроміс, який коштує вам маршрутів і слухачів у `boot()`.