Skip to content

feat(container): add scoped bindings - #2279

Open
osbre wants to merge 1 commit into
tempestphp:3.xfrom
osbre:feat/scoped-container-bindings
Open

feat(container): add scoped bindings#2279
osbre wants to merge 1 commit into
tempestphp:3.xfrom
osbre:feat/scoped-container-bindings

Conversation

@osbre

@osbre osbre commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Adds a new scoped lifetime (Container::scoped() and #[Scoped]) for dependencies that live for a single request or lifecycle, replacing the manual RouterReset workaround from #2278.

Problem

Tempest previously supported only transient and singleton lifetimes. State that is valid for a single request or job - such as MatchedRoute, tenant contexts, or authenticated users - had no native lifetime representation.

This resulted in cross-request leaks or clumsy workarounds, such as RouterReset and custom Resettable implementations, forcing every package holding per-request state to write its own manual cleanup logic.

Solution

  • Introduces a third lifetime, available via container method or attribute:
$container->scoped(MatchedRoute::class, $matchedRoute);
$container->scoped(TenantContext::class, fn () => new TenantContext(...));
#[Scoped]
final class TenantContext { /* ... */ }
  • Scoped bindings act like singletons during a single lifecycle and are cleared when Container::reset() is called.
  • Definitions vs. Instances on Reset: Callable definitions are kept so they can provide fresh instances in the next lifecycle. Concrete instances cannot be rebuilt, so their bindings are removed entirely.
  • #[Scoped] is supported everywhere #[Singleton] is: on autowired classes, on initializer classes, and on initialize() methods, including the tagged variant #[Scoped(tag: 'web')].
  • Re-registering a scoped dependency with singleton() clears its scoped registration, so an explicit singleton always survives a reset.
  • container:show lists scoped bindings under their own section, instead of mixing them into the singletons.
  • MatchRouteMiddleware now binds MatchedRoute as scoped, and RouterReset has been deleted.
  • Documented in docs/1-essentials/05-container.md.

Design Choices

  • Defaults remain untouched: Transient stays the default strategy, and singletons are unmodified.
  • Explicit naming: Chose dedicated scoped() and #[Scoped] declarations rather than hiding behavior behind boolean flags on singleton methods.
  • Scope boundaries: Container::reset() is still only triggered via FrameworkKernel::shutdown(). This PR introduces the binding mechanism, leaving automatic scope creation per request/job for a future update. This is also why MatchRouteMiddleware keeps the unregister() call from fix(router): clear the matched route between requests #2278: two requests within a single test share a container without a reset in between.

Tests

Added 8 tests in ContainerTest covering:

  1. Sharing within a lifecycle
  2. Discarding an instance on reset
  3. Discarding a tagged instance on reset
  4. Keeping a callable definition across a reset
  5. Leaving plain singletons untouched
  6. Re-registering a scoped dependency as a singleton
  7. The #[Scoped] attribute
  8. A tagged #[Scoped] initializer

Plus one integration test asserting container:show lists scoped bindings separately.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Benchmark Results

Comparison of feat/scoped-container-bindings against 3.x (16a1a2a6db94d90ca803ae4442520b880813d4e5).

Open to see the benchmark results
Benchmark Set Mem. Peak Time Variability
ContainerBench(benchAutowireSimple) - 4.083mb +0.37% 4.835μs +12.07% ±0.81% +26.50%
ContainerBench(benchAutowireNested) - 4.160mb +0.31% 15.916μs +5.88% ±0.47% -60.48%
ContainerBench(benchRegisterSingletonInstance) - 6.106mb +4.26% 1.952μs +20.78% ±1.09% -44.96%
ContainerBench(benchRegisterDefinition) - 6.776mb +3.84% 1.911μs +15.04% ±1.51% +72.81%
ContainerBench(benchResolveWithInitializer) - 4.123mb +0.28% 7.165μs +9.73% ±0.87% -14.60%
ContainerBench(benchRegisterInitializer) - 6.419mb +6.12% 5.227μs +10.51% ±0.86% -36.72%
ContainerBench(benchResolveWithDynamicInitializer) - 4.127mb +0.21% 5.162μs +11.72% ±0.43% -74.86%
ContainerBench(benchRegisterDynamicInitializer) - 6.244mb +5.22% 2.683μs +13.69% ±0.53% -53.68%
ContainerBench(benchRegisterClosureSingleton) - 6.453mb +4.03% 1.993μs +16.16% ±0.66% +12.08%
ContainerBench(benchInvokeClosure) - 4.134mb +0.24% 11.302μs +5.09% ±0.73% +23.27%

Generated by phpbench against commit a9fdc72

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant