diff --git a/AGENTS.md b/AGENTS.md index 1033661a75..82d1bd022b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,7 +220,7 @@ Hypervel's container keeps Laravel's named API surface — `bind()`, `singleton( ### Resolution semantics vs Laravel -The critical difference: **unbound concrete classes are auto-singletoned**. In Laravel, `make()` on a class with no binding builds a fresh instance every call. In Hypervel, the first resolution caches the instance (in `$autoSingletons`) for the worker lifetime — in Swoole's long-running process model services are stateless singletons by design, and re-creating them on every resolution wastes CPU and memory. Explicit bindings override this (bound classes follow their binding type), and `SelfBuilding` classes are excluded. +The critical difference: **unbound concrete classes are auto-singletoned**. In Laravel, `make()` on a class with no binding builds a fresh instance every call. In Hypervel, the first resolution caches the instance (in `$autoSingletons`) for the worker lifetime — in Swoole's long-running process model services are stateless singletons by design, and re-creating them on every resolution wastes CPU and memory. Explicit bindings override this (bound classes follow their binding type), and `SelfBuilding` and `Transient` classes are excluded. | Registration | Laravel | Hypervel | |---|---|---| @@ -231,19 +231,20 @@ The critical difference: **unbound concrete classes are auto-singletoned**. In L | `make($abstract, $params)` / `makeWith()` | Contextual build — never cached | Same — parameters bypass the singleton, scoped, and auto-singleton caches | | `build($concrete)` | Constructs the concrete directly, bypassing bindings and caches for the top-level class | Same; Hypervel adds `buildWith($concrete, $params)`. Nested constructor dependencies still resolve through the container | | `implements SelfBuilding` | Container calls the class's static `newInstance()` | Same, and it also skips auto-singletoning | +| `implements Transient` | No equivalent marker | Unbound resolutions are always fresh; explicit bindings still determine the lifetime. Eloquent models inherit this marker from `Model` | Rules that follow from this: -- **Classes that capture per-request data in their constructor or accumulate mutable state must not be auto-singletoned** — pick the correct lifetime: `scoped()` for one instance per coroutine/request, `bind()` for a fresh instance per resolution, `build()`/`buildWith()` for direct construction at the call site, or `SelfBuilding` for class-controlled construction. An existing class like that being auto-singletoned is a coroutine-safety bug — STOP and report it. +- **Classes that capture per-request data in their constructor or accumulate mutable state must not be auto-singletoned** — pick the correct lifetime: `scoped()` for one instance per coroutine/request, `bind()` for a fresh instance per resolution, `build()`/`buildWith()` for direct construction at the call site, `SelfBuilding` for class-controlled construction, or `Transient` when freshness is intrinsic to the whole class hierarchy. An existing class like that being auto-singletoned is a coroutine-safety bug — STOP and report it. - **Most classes are safe as auto-singletons:** services, middleware, listeners, factories, formatters — stateless or process-global by nature. - **Do not use `build()` as a drop-in freshness replacement for `make()`** when explicit bindings, test swaps, aliases, or resolving callbacks must be honored — it bypasses top-level binding lookups, aliases, and caches by design. ### Choosing a binding type - Stateless and shared for the worker lifetime → `singleton()`. -- Fresh mutable object per resolution → `bind()`. +- Fresh mutable object per resolution → `bind()`, or `Transient` when that lifetime is intrinsic to the whole class hierarchy. - State isolated per coroutine / request → `scoped()`. -- Concrete class with no separate abstract → don't bind it at all; auto-singletoning covers it. +- Concrete class with no separate abstract → don't bind it at all; auto-singletoning covers it unless the class implements `Transient` or `SelfBuilding`. ### Binding patterns @@ -264,7 +265,7 @@ $this->app->singleton('auth', fn ($app) => new AuthManager($app)); $this->app->singleton(FormatterInterface::class, DefaultFormatter::class); ``` -**3. Abstract and concrete are the same class — do not bind at all.** Hypervel's container auto-singletons unbound concrete classes on first resolution. An explicit `singleton(Foo::class)` is redundant: +**3. Abstract and concrete are the same class — do not bind merely to share it.** Hypervel's container auto-singletons unbound concrete classes on first resolution. An explicit `singleton(Foo::class)` is redundant unless the class declares an intrinsic fresh lifetime through `Transient` or `SelfBuilding`: ```php // Wrong: redundant; auto-singleton handles this. @@ -345,7 +346,7 @@ Decide where state lives before writing code: | Immutable metadata shared by all requests | Static property cache or worker-lifetime singleton | | Stateless service shared by all requests | `singleton()` or auto-singleton (see Container) | | Mutable state for one request, operation, or coroutine | `CoroutineContext` or a `scoped()` binding | -| Fresh mutable object per resolution | `bind()` or contextual parameters | +| Fresh mutable object per resolution | `bind()`, contextual parameters, or `Transient` for an intrinsically fresh class hierarchy | - **Use `Hypervel\Context\CoroutineContext` for invocation-scoped state** — anything that must not be visible to other concurrent coroutines in the same worker. Static properties and singleton fields leak across coroutines: whatever one coroutine sets becomes visible to all others in the worker. Use the established key-naming convention: `__.` value prefix, `_CONTEXT_KEY` / `_CONTEXT_KEY_PREFIX` constant suffixes, public only when other classes or tests reference the constant. Do not use `Hypervel\Support\Facades\Context` as the low-level coroutine store; it provides Laravel-style application context instead. - **Configure process-global values only during worker boot** — config is a process-global singleton, so `Config::set()` during request handling changes behavior for every concurrent request in the worker. Never mutate config for request-specific behavior; use `CoroutineContext` or middleware instead. Provider boot-time configuration is fine — it runs once per worker. diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 30c52356c6..8d58b59a84 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -16,6 +16,7 @@ use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Container\ContextualBindingBuilder as ContextualBindingBuilderContract; use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Contracts\Container\Transient; use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Traits\ReflectsClosures; use InvalidArgumentException; @@ -1225,15 +1226,16 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise } } elseif ($raiseEvents && ! isset($this->bindings[$abstract]) && is_string($concrete) && class_exists($concrete) && ! is_a($concrete, SelfBuilding::class, true) + && ! is_a($concrete, Transient::class, true) ) { // Auto-singleton: unbound concrete classes are cached for Swoole performance. // In Swoole's long-running process model, services are stateless singletons // by design. Re-creating them on every resolution wastes CPU and memory. // // Explicit bind() overrides this — bound classes follow their binding type. - // SelfBuilding classes are excluded — they control their own construction - // via newInstance() and typically read runtime state (config, request data) - // that may change between resolutions. Use explicit singleton() to opt in. + // SelfBuilding classes control their own construction, while Transient + // classes declare that every unbound resolution requires a fresh instance. + // Use an explicit singleton() binding to opt either lifetime into caching. // // Skipped when raiseEvents is false (internal binding resolution via getClosure) // so that concretes resolved as part of scoped/singleton bindings don't get @@ -1330,7 +1332,8 @@ protected function shouldCoordinateSharedResolution( && ! isset($this->bindings[$abstract]) && is_string($concrete) && class_exists($concrete) - && ! is_a($concrete, SelfBuilding::class, true); + && ! is_a($concrete, SelfBuilding::class, true) + && ! is_a($concrete, Transient::class, true); } /** diff --git a/src/contracts/src/Container/Transient.php b/src/contracts/src/Container/Transient.php new file mode 100644 index 0000000000..bf139098fd --- /dev/null +++ b/src/contracts/src/Container/Transient.php @@ -0,0 +1,12 @@ + [!NOTE] -> Hypervel will auto-singleton (automatically cache) unbound concrete classes for the worker's lifetime — the first resolution of `Service` constructs an instance, and every subsequent resolution returns that same instance until the worker restarts. This is the right default for stateless services. Classes whose constructors capture per-call state should be [bound explicitly](#binding) or resolved with [`build()`](#forcing-a-fresh-instance). +> Hypervel will auto-singleton (automatically cache) unbound concrete classes for the worker's lifetime — the first resolution of `Service` constructs an instance, and every subsequent resolution returns that same instance until the worker restarts. This is the right default for stateless services. Classes whose constructors capture per-call state should be [bound explicitly](#binding), resolved with [`build()`](#forcing-a-fresh-instance), or marked as [transient](#transient-classes) when freshness is part of the class's design. ### When to Utilize the Container @@ -116,8 +117,9 @@ Because Hypervel runs inside a long-running Swoole worker, instance lifecycles a |---|---|---| | Fresh instance every call, ignoring all bindings and caches | `build($class)` | Always constructs a new instance. Nested constructor dependencies are still resolved through the container. | | Fresh instance with parameter overrides | `buildWith($class, $params)` | Same as `build()` but applies the given parameter overrides during construction. | +| Class hierarchy always requires a fresh instance | `implements Transient` | Builds a new instance for every unbound `make()` or `get()` call. Explicit bindings still determine the lifetime when present. | | Class declares its own factory | `implements SelfBuilding` + static `newInstance()` | Container invokes the static factory with DI on its parameters. Skips auto-singletoning by default; honors any explicit `singleton()` / `scoped()` binding. | -| Resolve respecting bindings and caching | `make($class)` | Honors `bind()` / `singleton()` / `scoped()`. Auto-singletons unbound concrete classes for the worker's lifetime. | +| Resolve respecting bindings and caching | `make($class)` | Honors `bind()` / `singleton()` / `scoped()`. Auto-singletons unbound concrete classes unless they implement `Transient` or `SelfBuilding`. | | Resolve with parameter overrides | `make($class, $params)` / `makeWith()` | Same as `make()` but contextual parameters bypass all caching. | | One instance per worker | `$app->singleton($abstract, ...)` or `#[Singleton]` | Cached for the worker's lifetime. Lives until the worker restarts. | | One instance per coroutine (per request / job) | `$app->scoped($abstract, ...)` or `#[Scoped]` | Cached in [CoroutineContext](/docs/{{version}}/coroutine-context) for the lifetime of the coroutine handling the request or job. | @@ -134,6 +136,7 @@ Most application code does not pick a lifecycle deliberately — it just type-hi - **Service that should hold per-request state** (request-derived caches, accumulated context): `scoped()`. The instance dies at the end of the coroutine (request / job). - **Service that should be built once and shared for the worker's lifetime** (heavy bootstrap, immutable configuration): `singleton()` is explicit; auto-singletoning achieves the same thing for unbound classes. - **Object that takes per-call inputs in its constructor** (builders, view components, form-request-style classes): `bind()` so each `make()` returns fresh, or skip the binding entirely and call `build()` / `buildWith()` at the resolution site. +- **Class hierarchy whose instances are always mutable and independent** (Eloquent models and similar value holders): implement `Transient` once on the base class so every unbound resolution is fresh. ### Per-Call State on Shared Instances @@ -158,11 +161,11 @@ $report = $this->app->make(ReportBuilder::class); $report = $this->app->build(ReportBuilder::class); ``` -Alternatively, mark the class with [`SelfBuilding`](#self-building-classes) and leave it unbound — every `make()` will then call `newInstance` and rebuild the instance from scratch. +When freshness is part of the class's design, implement [`Transient`](#transient-classes). If the class also needs to control how it is constructed, use [`SelfBuilding`](#self-building-classes) instead. The same caution applies to mutating state on a worker-lifetime singleton at runtime — anything you assign to `$this->foo` on a shared instance persists across every request that worker handles. For per-request state that lives on a shared service, use [CoroutineContext](/docs/{{version}}/coroutine-context) instead of instance properties. -Framework code that ships with Hypervel — view components, form requests, and so on — already routes through the fresh-instance path when needed, so you only need to think about this for your own classes. +Framework code that ships with Hypervel already chooses the required lifecycle. For example, every Eloquent model is transient, form requests are self-building, and view components use the fresh-instance path. ## Binding @@ -218,7 +221,7 @@ App::bind(function (Application $app): Transistor { ``` > [!NOTE] -> You don't need to bind classes the container can resolve via reflection. Hypervel will auto-singleton the resolved instance for the worker's lifetime, which is the right behavior for stateless services. Bind the class explicitly with `bind()` if you need a fresh instance per call, or call [`build()`](#forcing-a-fresh-instance) at the resolution site. +> You don't need to bind classes the container can resolve via reflection. Hypervel will auto-singleton the resolved instance for the worker's lifetime, which is the right behavior for stateless services. Bind the class explicitly with `bind()` if one registration needs a fresh instance per call, call [`build()`](#forcing-a-fresh-instance) at the resolution site, or implement [`Transient`](#transient-classes) when every unbound resolution of the class hierarchy must be fresh. #### Binding A Singleton @@ -747,6 +750,30 @@ Nested constructor dependencies are still resolved through the container, so the `buildWith` is the right choice when a class needs parameter overrides and must not be cached, for example a builder object or a class whose constructor captures per-call state. Internally, Hypervel uses this method to instantiate view components so each render gets a fresh instance even though the component class has no explicit binding. + +### Transient Classes + +When every instance of a class hierarchy is mutable and independent, implement the `Hypervel\Contracts\Container\Transient` marker interface on its base class. Unbound transient classes are constructed for every `make()` and `get()` call while retaining normal dependency injection, aliases, extenders, and resolving callbacks: + +```php + ### Self-Building Classes diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 208392a656..e9279e6d86 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -405,10 +405,11 @@ Container lifecycles are adapted for Swoole: | One instance per request or job coroutine | `scoped()` | | One instance per worker | `singleton()` | | Fresh instance at the call site | `build()` or `buildWith()` | +| Fresh instance for every unbound resolution of a class hierarchy | Implement `Hypervel\Contracts\Container\Transient` on its base class | | Resolve using bindings and lifecycle rules | `make()` | > [!WARNING] -> Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, or construct a fresh instance with `build()`. +> Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, construct a fresh instance with `build()`, or implement `Transient` when every subclass must always be fresh. Eloquent models already implement `Transient`. ### Coroutine-Aware Dependencies diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index ae1f59f549..062a3c00d0 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -15,6 +15,7 @@ use Hypervel\Contracts\Container\CircularDependencyException; use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Contracts\Container\Transient; use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -1480,6 +1481,74 @@ public function testAutoSingletonCachesUnboundConcreteClass() $this->assertSame($first, $second); } + public function testTransientClassIsNotAutoSingletoned(): void + { + $container = new Container; + + $first = $container->make(TransientStub::class); + $second = $container->get(TransientStub::class); + + $this->assertNotSame($first, $second); + } + + public function testTransientLifetimeIsInheritedBySubclasses(): void + { + $container = new Container; + + $first = $container->make(TransientChildStub::class); + $second = $container->make(TransientChildStub::class); + + $this->assertNotSame($first, $second); + } + + public function testTransientClassCanBeExplicitlySingletoned(): void + { + $container = new Container; + $container->singleton(TransientStub::class); + + $first = $container->make(TransientStub::class); + $second = $container->make(TransientStub::class); + + $this->assertSame($first, $second); + } + + public function testTransientClassCanBeExplicitlyScoped(): void + { + $container = new Container; + $container->scoped(TransientStub::class); + + $first = $container->make(TransientStub::class); + $second = $container->make(TransientStub::class); + + $this->assertSame($first, $second); + } + + public function testTransientClassCanBeRegisteredAsAnInstance(): void + { + $container = new Container; + $instance = new TransientStub; + $container->instance(TransientStub::class, $instance); + + $this->assertSame($instance, $container->make(TransientStub::class)); + } + + public function testTransientClassExtendersRunForEveryInstance(): void + { + $container = new Container; + $container->extend(TransientStub::class, function (TransientStub $instance): TransientStub { + $instance->marks[] = 'extended'; + + return $instance; + }); + + $first = $container->make(TransientStub::class); + $second = $container->make(TransientStub::class); + + $this->assertNotSame($first, $second); + $this->assertSame(['extended'], $first->marks); + $this->assertSame(['extended'], $second->marks); + } + public function testAutoSingletonSkippedWhenParametersProvided() { $container = new Container; @@ -1927,6 +1996,15 @@ public function __construct( } } +class TransientStub implements Transient +{ + public array $marks = []; +} + +class TransientChildStub extends TransientStub +{ +} + class SelfBuildingCounterStub implements SelfBuilding { public function __construct( diff --git a/tests/Container/CoroutineSafetyTest.php b/tests/Container/CoroutineSafetyTest.php index 5527f9a9ee..a3654067a8 100644 --- a/tests/Container/CoroutineSafetyTest.php +++ b/tests/Container/CoroutineSafetyTest.php @@ -8,6 +8,7 @@ use Hypervel\Container\SharedResolution; use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Container\CircularDependencyException; +use Hypervel\Contracts\Container\Transient; use Hypervel\Tests\TestCase; use RuntimeException; use stdClass; @@ -346,6 +347,43 @@ public function testConcurrentAutoSingletonConstructionConverges(): void $this->assertSame($results['owner'], $results['waiter']); } + public function testConcurrentTransientConstructionDoesNotCoordinateOrShare(): void + { + $container = new Container; + $dependencyEntered = new Channel(2); + $releaseDependency = new Channel(2); + + $container->bind(CoroutineCoordinatedDependency::class, function () use ($dependencyEntered, $releaseDependency) { + $dependencyEntered->push(true); + $releaseDependency->pop(); + + return new CoroutineCoordinatedDependency; + }); + CoroutineTransientService::$constructions = 0; + + try { + $results = parallel([ + 'first' => fn () => $container->make(CoroutineTransientService::class), + 'second' => fn () => $container->make(CoroutineTransientService::class), + 'release' => function () use ($dependencyEntered, $releaseDependency): bool { + $firstEntered = $dependencyEntered->pop(1); + $secondEntered = $dependencyEntered->pop(1); + $releaseDependency->push(true); + $releaseDependency->push(true); + + return $firstEntered === true && $secondEntered === true; + }, + ]); + $constructions = CoroutineTransientService::$constructions; + } finally { + CoroutineTransientService::$constructions = 0; + } + + $this->assertTrue($results['release']); + $this->assertSame(2, $constructions); + $this->assertNotSame($results['first'], $results['second']); + } + public function testConcurrentFailureFansOutAndAllowsRetry(): void { $container = new CoroutineInspectingContainer; @@ -685,3 +723,13 @@ public function __construct(public readonly CoroutineCoordinatedDependency $depe ++self::$constructions; } } + +class CoroutineTransientService implements Transient +{ + public static int $constructions = 0; + + public function __construct(public readonly CoroutineCoordinatedDependency $dependency) + { + ++self::$constructions; + } +} diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php index e1f0184771..4963f10312 100644 --- a/tests/Routing/ImplicitRouteBindingTest.php +++ b/tests/Routing/ImplicitRouteBindingTest.php @@ -14,6 +14,7 @@ use Hypervel\Tests\Routing\Fixtures\CategoryBackedEnum; use Hypervel\Tests\Routing\Fixtures\CategoryEnum; use Hypervel\Tests\Routing\RoutingTestCase; +use LogicException; use ReflectionProperty; use WeakMap; @@ -144,6 +145,24 @@ public function testItCanResolveTheImplicitModelRouteBindingsForTheGivenRoute(): ImplicitRouteBinding::resolveForRoute($container, $route); } + public function testItUsesAFreshModelForEachImplicitRouteBinding(): void + { + $container = Container::getInstance(); + + foreach ([1, 2] as $identifier) { + $action = ['uses' => function (FreshImplicitRouteBindingUser $user) { + return $user; + }]; + $route = new Route('GET', '/test/{user}', $action); + $route->bind(Request::create("/test/{$identifier}")); + $route->prepareForSerialization(); + + ImplicitRouteBinding::resolveForRoute($container, $route); + + $this->assertSame($identifier, $route->parameter('user')->getKey()); + } + } + public function testItResolvesInvokableObjectSignatureParameters(): void { $route = new Route( @@ -217,6 +236,22 @@ class ImplicitRouteBindingUser extends Model { } +class FreshImplicitRouteBindingUser extends Model +{ + private bool $resolved = false; + + public function resolveRouteBinding(mixed $value, ?string $field = null): ?self + { + if ($this->resolved) { + throw new LogicException('The route binding model was reused.'); + } + + $this->resolved = true; + + return (new static)->setAttribute($this->getRouteKeyName(), $value); + } +} + class EmptyParameterRoute extends Route { public int $signatureParameterCalls = 0; diff --git a/tests/Routing/RouteBindingTest.php b/tests/Routing/RouteBindingTest.php index 8f5e2d3e82..1393c45e4e 100644 --- a/tests/Routing/RouteBindingTest.php +++ b/tests/Routing/RouteBindingTest.php @@ -11,6 +11,7 @@ use Hypervel\Routing\Route; use Hypervel\Routing\RouteBinding; use Hypervel\Tests\Routing\RoutingTestCase; +use LogicException; class RouteBindingTest extends RoutingTestCase { @@ -25,6 +26,20 @@ public function testItCanResolveTheExplicitModelForTheGivenRoute() $this->assertInstanceOf(ExplicitRouteBindingUser::class, $callback(1, $route)); } + public function testItUsesAFreshModelForEachExplicitRouteBinding(): void + { + $container = Container::getInstance(); + $route = new Route('GET', '/users/{user}', function () { + }); + $callback = RouteBinding::forModel($container, FreshExplicitRouteBindingUser::class); + + $first = $callback(1, $route); + $second = $callback(2, $route); + + $this->assertSame(1, $first->getKey()); + $this->assertSame(2, $second->getKey()); + } + public function testItCannotResolveTheExplicitSoftDeletedModelForTheGivenRoute() { $container = Container::getInstance(); @@ -58,6 +73,22 @@ public function resolveRouteBinding(mixed $value, ?string $field = null): ?self } } +class FreshExplicitRouteBindingUser extends Model +{ + private bool $resolved = false; + + public function resolveRouteBinding(mixed $value, ?string $field = null): ?self + { + if ($this->resolved) { + throw new LogicException('The route binding model was reused.'); + } + + $this->resolved = true; + + return (new static)->setAttribute($this->getRouteKeyName(), $value); + } +} + class ExplicitRouteBindingSoftDeletableUser extends Model { use SoftDeletes; diff --git a/tests/Routing/RouteDependencyResolverTest.php b/tests/Routing/RouteDependencyResolverTest.php new file mode 100644 index 0000000000..697e4d01f0 --- /dev/null +++ b/tests/Routing/RouteDependencyResolverTest.php @@ -0,0 +1,41 @@ +exists || $model->getAttribute('leaked') !== null; + $model->exists = true; + $model->setAttribute('leaked', true); + + return [$modelWasDirty, ++$service->hits]; + }; + $route = new Route('GET', '/injected-model', $action); + $route->bind(Request::create('/injected-model')); + + $this->assertSame([false, 1], $dispatcher->dispatch($route, $action)); + $this->assertSame([false, 2], $dispatcher->dispatch($route, $action)); + } +} + +class InjectedRouteModel extends Model +{ +} + +class InjectedRouteService +{ + public int $hits = 0; +}