<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Що робить service provider і чим bind відрізняється від singleton і scoped?

Провайдер - єдине штатне місце для збирання застосунку: `register()` кладе привʼязки в контейнер, `boot()` викликається вже після того, як зареєструвалися всі провайдери. `bind` дає новий обʼєкт на кожне розвʼязання, `singleton` тримає один на весь життєвий цикл контейнера, а `scoped` - той самий singleton, який Octane і черговий воркер скидають між запитами та задачами.

Провайдер лежить у `bootstrap/providers.php`, привʼязки з нього працюють, а `Route::` і `Event::listen` у його `boot()` мовчать. Що не так?
Чому в `register()` не можна викликати `$this->app->make()`?
Під Octane сервіс віддав другому користувачу дані першого. Там був `bind`, `singleton` чи `scoped`?
У нас звичайний PHP-FPM. Чи є взагалі різниця між `singleton` і `scoped`?
service provider deferred singleton scoped Octane контекстна привʼязка

Провайдери - це список класів, який 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()` в усіх, і саме тому в `boot()` вже можна розраховувати на чужі привʼязки, а в `register()` ні.
Що `singleton()` - це `bind($abstract, $concrete, shared: true)`, тобто той самий механізм плюс збереження результату в `$instances`; час життя обмежений життям контейнера, а не «назавжди».
Що `scoped()` додатково реєструє abstract у `$scopedInstances`, і `forgetScopedInstances()` викидає саме їх: під Octane це робить `FlushTemporaryContainerInstances`, у `queue:work` - `Worker::resetScope()` перед кожною задачею.
Що `DeferrableProvider` + `provides()` відкладають реєстрацію до першого `make()` потрібної звʼязки, а карта «звʼязка → провайдер» лежить у `bootstrap/cache/services.php`.
Що контекстна привʼязка `when()->needs()->give()` вирішує дві задачі, яких autowiring не тягне: скаляри в конструкторі й дві реалізації одного інтерфейсу для різних споживачів.
Класти `Route::`, `Event::listen()`, `Gate::define()` або `View::composer()` у відкладений провайдер: його `boot()` виконається лише тоді, коли хтось витягне звʼязку з `provides()`, а до того маршрут просто не існує.
Оголосити провайдер `DeferrableProvider` і залишити `provides()` порожнім або з іншим ключем, ніж той, за яким реально резолвлять: привʼязка не зареєструється ніколи, а помилка буде `Target is not instantiable`.
Резолвити залежності прямо в `register()`: провайдер потрібного сервісу може бути ще не зареєстрований, а обʼєкт заморозиться до того, як інший провайдер устигне його `extend()`.
Ставити `singleton` сервісу, який тримає `Request`, поточного користувача, `locale` чи Eloquent-модель: під FPM це непомітно, під Octane і в довгих чергових воркерах стан протікає між запитами.
Вважати, що `scoped` рятує від будь-якого протікання: воно скидає лише сам обʼєкт із контейнера, а замикання, яке захопило `$request` у `singleton`, або статична властивість класу лишаються.
Плодити провайдер під кожен сервіс: конкретні класи з типізованим конструктором контейнер створює без жодної реєстрації, провайдер потрібен інтерфейсам, скалярам і налаштуванню пакетів.
ПОРАДА

Тримайте дві осі й не змішуйте їх. Перша - час: `register()` до всього, `boot()` після всіх реєстрацій. Друга - життя обʼєкта: `bind` (щоразу новий), `singleton` (один на життя контейнера), `scoped` (один на запит або задачу). Далі одним реченням додайте, що `scoped` існує саме заради Octane й довгих воркерів, а `deferred` - це компроміс, який коштує вам маршрутів і слухачів у `boot()`.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`ProviderRepository` кладе відкладені провайдери в маніфест `bootstrap/cache/services.php` як карту «звʼязка → провайдер», і `Container::make()` піднімає провайдер лише при першому зверненні до звʼязки з `provides()`; поки цього не сталося, його `boot()` не виконувався, тож маршрутів і слухачів немає. У PHP-FPM `scoped` і `singleton` поводяться однаково, бо контейнер живе рівно один запит: `scoped` створює обʼєкт один раз, просто додатково реєструє його в `$scopedInstances` для `forgetScopedInstances()`. Контекстна привʼязка діє тільки на перелічених у `when()` споживачів і навіть навмисно обходить кеш `$instances`, щоб не зафіксувати свою реалізацію для решти застосунку.