Skip to content

Preventing Memory Leaks with Scoped Bindings

When you deploy to FrankenPHP or other long-running workers, your app boots once and handles thousands of requests. You need some state to persist across requests and other state to reset.

If you use singleton() for request-specific state, the same instance lives across all requests:

app()->singleton(RequestContext::class, fn() => new RequestContext());

This causes data from request A to appear in request B—a serious bug.

Scoped bindings cache for the lifetime of one request, then reset:

app()->scoped(RequestContext::class, fn() => new RequestContext());

Within a single request, you get the same instance every time. The next request gets a fresh instance.

MethodCreates new instance?Persists across requests?
bind()Every timeNo (because it’s always new)
singleton()Once, foreverYes
scoped()Once per requestNo

Anything specific to the current request needs scoping:

app()
->scoped(RequestContext::class, fn() => new RequestContext())
->scoped(UserSession::class, fn() => new UserSession())
->scoped(RequestLogger::class, fn() => new RequestLogger());

Expensive resources that can safely be shared need singletons:

app()
->singleton(DB::class, fn() => new DB(app()->env('DATABASE_URL')))
->singleton(CacheInterface::class, fn() => new RedisCache(app()->env('REDIS_URL')))
->singleton(Config::class, fn() => new Config());

In traditional PHP deployment (Apache, PHP-FPM), the process dies after each request. There’s no practical difference between singleton() and scoped() because everything resets anyway.

Still, use scoped() for request-specific state. It documents your intent and makes the app safe for worker deployment later.