From ef7bcbd62e86bc73bf6612245cc144cd9dc772cb Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:43:51 +0600 Subject: [PATCH 1/5] refactor(container)!: split core responsibilities and add resolution engine Refactor Container to compose dedicated components: Registry (bindings, aliases, tags), ResolutionEngine (reflection-based autowiring and caching), and CallbackInvoker (DI-aware callback and method invocation). Introduce CircularDependencyException and extend ContainerException and NotFoundException documentation. Add new APIs including bind, singleton, alias, tag, tagged, make, call, set, has, resolved_id, forget_instances, and flush. Implement shared instance caching, alias resolution, and circular dependency detection. Include extensive unit and integration tests covering deep dependency trees, interface and alias chaining, variadic and nullable parameters, visibility handling, and method invocation behavior. BREAKING CHANGE: container internals and resolution flow have been restructured and public APIs expanded/adjusted. --- src/CallbackInvoker.php | 171 ++++++++ src/Container.php | 390 ++++++++++++------ src/Exception/CircularDependencyException.php | 23 ++ src/Exception/ContainerException.php | 14 + src/Exception/NotFoundException.php | 14 + src/Registry.php | 201 +++++++++ src/ResolutionEngine.php | 243 +++++++++++ tests/Fixtures/Contracts/InterfaceA.php | 3 + tests/Fixtures/Contracts/InterfaceB.php | 3 + tests/Fixtures/Contracts/TestInterface.php | 5 + tests/Fixtures/Models/CircularA.php | 8 + tests/Fixtures/Models/CircularB.php | 8 + .../Models/ClassWithOptionalParam.php | 11 + tests/Fixtures/Models/ClassWithParams.php | 14 + tests/Fixtures/Models/ConcreteC.php | 4 + tests/Fixtures/Models/ConcreteClass.php | 5 + .../Models/ConcreteImplementation.php | 7 + tests/Fixtures/Models/InvokableClass.php | 8 + tests/Fixtures/Models/Level1.php | 8 + tests/Fixtures/Models/Level2.php | 8 + tests/Fixtures/Models/Level3.php | 8 + tests/Fixtures/Models/Level4.php | 8 + tests/Fixtures/Models/Level5.php | 3 + .../Fixtures/Models/MethodInjectionClass.php | 13 + .../Fixtures/Models/MixedDependencyClass.php | 12 + tests/Fixtures/Models/NullablePrecClass.php | 10 + .../Fixtures/Models/ServiceWithDependency.php | 11 + .../Models/ServiceWithNestedDependency.php | 11 + tests/Fixtures/Models/VariadicClass.php | 10 + tests/Fixtures/Models/VisibilityClass.php | 13 + tests/Integration/BasicBindingTest.php | 100 +++++ tests/Integration/CircularDependencyTest.php | 139 +++++++ tests/Integration/ContainerLifecycleTest.php | 110 +++++ tests/Integration/ContainerRobustnessTest.php | 165 ++++++++ tests/Integration/ExtensionTaggingTest.php | 89 ++++ tests/Integration/RecursiveResolutionTest.php | 90 ++++ tests/Integration/TypeSafetyTest.php | 80 ++++ tests/Unit/ContainerTest.php | 178 +++++--- 38 files changed, 2024 insertions(+), 174 deletions(-) create mode 100644 src/CallbackInvoker.php create mode 100644 src/Exception/CircularDependencyException.php create mode 100644 src/Registry.php create mode 100644 src/ResolutionEngine.php create mode 100644 tests/Fixtures/Contracts/InterfaceA.php create mode 100644 tests/Fixtures/Contracts/InterfaceB.php create mode 100644 tests/Fixtures/Contracts/TestInterface.php create mode 100644 tests/Fixtures/Models/CircularA.php create mode 100644 tests/Fixtures/Models/CircularB.php create mode 100644 tests/Fixtures/Models/ClassWithOptionalParam.php create mode 100644 tests/Fixtures/Models/ClassWithParams.php create mode 100644 tests/Fixtures/Models/ConcreteC.php create mode 100644 tests/Fixtures/Models/ConcreteClass.php create mode 100644 tests/Fixtures/Models/ConcreteImplementation.php create mode 100644 tests/Fixtures/Models/InvokableClass.php create mode 100644 tests/Fixtures/Models/Level1.php create mode 100644 tests/Fixtures/Models/Level2.php create mode 100644 tests/Fixtures/Models/Level3.php create mode 100644 tests/Fixtures/Models/Level4.php create mode 100644 tests/Fixtures/Models/Level5.php create mode 100644 tests/Fixtures/Models/MethodInjectionClass.php create mode 100644 tests/Fixtures/Models/MixedDependencyClass.php create mode 100644 tests/Fixtures/Models/NullablePrecClass.php create mode 100644 tests/Fixtures/Models/ServiceWithDependency.php create mode 100644 tests/Fixtures/Models/ServiceWithNestedDependency.php create mode 100644 tests/Fixtures/Models/VariadicClass.php create mode 100644 tests/Fixtures/Models/VisibilityClass.php create mode 100644 tests/Integration/BasicBindingTest.php create mode 100644 tests/Integration/CircularDependencyTest.php create mode 100644 tests/Integration/ContainerLifecycleTest.php create mode 100644 tests/Integration/ContainerRobustnessTest.php create mode 100644 tests/Integration/ExtensionTaggingTest.php create mode 100644 tests/Integration/RecursiveResolutionTest.php create mode 100644 tests/Integration/TypeSafetyTest.php diff --git a/src/CallbackInvoker.php b/src/CallbackInvoker.php new file mode 100644 index 0000000..f8bfe84 --- /dev/null +++ b/src/CallbackInvoker.php @@ -0,0 +1,171 @@ +container = $container; + $this->engine = $engine; + } + + /** + * Call a callback with dependency injection. + * + * @param callable|array|string $callback + * @param array $parameters + * @return mixed + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the callback is invalid. + */ + public function call( $callback, array $parameters = [] ) { + [$resolved_callback, $metadata] = $this->resolve_callback( $callback ); + + $args = $this->engine->resolve_dependencies( $metadata->getParameters(), $parameters ); + + return call_user_func_array( $resolved_callback, $args ); + } + + /** + * Resolve the callback and its reflection metadata. + * + * @param mixed $callback + * @return array [callable, \ReflectionFunctionAbstract] + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the callback is not callable. + */ + protected function resolve_callback( $callback ) { + // 1. Handle Array Callbacks [class, method] or [instance, method] + if ( is_array( $callback ) ) { + return $this->resolve_array_callback( $callback ); + } + + // 2. Handle String Callbacks "Class::method" + if ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) { + return $this->resolve_array_callback( explode( '::', $callback ) ); + } + + // 3. Handle Invokable Objects + if ( is_object( $callback ) && ! ( $callback instanceof Closure ) ) { + $ref = $this->get_method_reflection( $callback, '__invoke' ); + return [$callback, $ref]; + } + + // 4. Handle Closures and simple string functions + if ( ! is_callable( $callback ) ) { + throw new ContainerException( "The provided callback is not callable." ); + } + + return [$callback, new ReflectionFunction( $callback )]; + } + + /** + * Resolve an array-based callback. + * + * @param array $callback + * @return array [callable, \ReflectionMethod] + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the class or service ID is not found. + */ + protected function resolve_array_callback( array $callback ) { + [$class_or_id, $method] = $callback; + + // Determine the class name for reflection + if ( is_object( $class_or_id ) ) { + $class = get_class( $class_or_id ); + $instance = $class_or_id; + } else { + $class = $this->container->resolved_id( $class_or_id ); + $instance = null; + + if ( ! class_exists( $class ) ) { + if ( $this->container->has( $class_or_id ) ) { + $instance = $this->container->get( $class_or_id ); + $class = get_class( $instance ); + } else { + throw new ContainerException( "Class or service ID {$class} not found." ); + } + } + } + + $ref = $this->get_method_reflection( $class, $method ); + + // If not static and we don't have an instance, resolve one from the container + if ( ! $ref->isStatic() && ! $instance ) { + $instance = $this->container->get( $class ); + } + + return [ $instance ? [$instance, $method] : [$class, $method], $ref ]; + } + + /** + * Get reflection of a method, with caching. + * + * @param object|string $class + * @param string $method + * @return ReflectionMethod + * @throws ReflectionException If reflection fails. + * @throws ContainerException If the method is not public. + */ + protected function get_method_reflection( $class, string $method ): ReflectionMethod { + $class_name = is_object( $class ) ? get_class( $class ) : $class; + $cache_key = $class_name . '::' . $method; + + if ( $cached = $this->engine->get_cached_method( $cache_key ) ) { + return $cached; + } + + $ref = new ReflectionMethod( $class_name, $method ); + + if ( ! $ref->isPublic() ) { + throw new ContainerException( "Method {$class_name}::{$method} is not public." ); + } + + $this->engine->cache_method( $cache_key, $ref ); + + return $ref; + } +} diff --git a/src/Container.php b/src/Container.php index aa41bcd..f6edbdf 100644 --- a/src/Container.php +++ b/src/Container.php @@ -1,202 +1,362 @@ instances[$id] ) ) { - return $this->instances[$id]; - } + protected $resolving = []; - $instance = $this->resolve( $id, $params ); - $this->instances[$id] = $instance; + /** + * The service registry instance. + * + * @var Registry + */ + protected $registry; + + /** + * The resolution engine instance. + * + * @var ResolutionEngine + */ + protected $engine; - return $instance; + /** + * The callback invoker instance. + * + * @var CallbackInvoker + */ + protected $invoker; + + /** + * Container constructor. + */ + public function __construct() { + $this->registry = new Registry(); + $this->engine = new ResolutionEngine( $this ); + $this->invoker = new CallbackInvoker( $this, $this->engine ); } /** - * @var array + * Register a transient binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return $this */ - protected $resolving = []; + public function bind( string $abstract, $concrete = null ): self { + $this->registry->bind( $abstract, $concrete ); + return $this; + } + + /** + * Register a shared (singleton) binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return $this + */ + public function singleton( string $abstract, $concrete = null ): self { + $this->registry->singleton( $abstract, $concrete ); + return $this; + } + + /** + * Alias a type to another name. + * + * @param string $abstract + * @param string $alias + * @return $this + */ + public function alias( string $abstract, string $alias ): self { + $this->registry->alias( $abstract, $alias ); + return $this; + } + + /** + * Assign a set of tags to a given binding. + * + * @param array|string $abstracts + * @param array|mixed $tags + * @return $this + */ + public function tag( $abstracts, $tags ): self { + $this->registry->tag( (array) $abstracts, (array) $tags ); + return $this; + } + + /** + * Resolve all bindings for a given tag. + * + * @param string $tag + * @return iterable + */ + public function tagged( string $tag ): iterable { + return array_map( + function ( $abstract ) { + return $this->get( $abstract ); + }, $this->registry->get_tag( $tag ) + ); + } /** - * Resolve a service instance (without storing as singleton). + * Get a service from the container. * - * @param string $id - * @param array $params + * @param string $id + * @param array $params * @return mixed - * @throws NotFoundException - * @throws ContainerException - * @throws \Exception + * @throws NotFoundException If the service cannot be resolved. + * @throws CircularDependencyException If a circularity is detected. + * @throws ContainerException If instantiation fails. */ - protected function resolve( string $id, array $params = [] ) { - if ( ! class_exists( $id ) ) { - throw new NotFoundException( "Service not found: {$id}" ); + public function get( string $id, array $params = [] ) { + // 1. If it's already in instance cache, return it + if ( isset( $this->instances[$id] ) ) { + if ( ! empty( $params ) ) { + throw new ContainerException( "Cannot pass parameters to an already instantiated shared service: {$id}" ); + } + return $this->instances[$id]; } + // 2. Circular dependency detection if ( isset( $this->resolving[$id] ) ) { - throw new ContainerException( "Circular dependency detected while resolving: {$id}" ); + throw new CircularDependencyException( "Circular dependency detected while resolving: {$id}" ); } $this->resolving[$id] = true; try { - $ref = new ReflectionClass( $id ); + // 3. Determine terminal ID and concrete + $resolved_id = $this->registry->resolve_id( $id ); + $concrete = $this->registry->get_concrete( $id ); + + // If the key is not in registry, try to autowire it as a class + $is_shared = $this->registry->has( $id ) ? $this->registry->is_shared( $id ) : true; + + // 4. Check if the resolved ID or concrete is already instantiated + $cached_key = null; + if ( isset( $this->instances[$resolved_id] ) ) { + $cached_key = $resolved_id; + } elseif ( is_string( $concrete ) && isset( $this->instances[$concrete] ) ) { + $cached_key = $concrete; + } - if ( ! $ref->isInstantiable() ) { - throw new ContainerException( "Class is not instantiable: {$id}" ); + if ( $is_shared && $cached_key !== null ) { + if ( ! empty( $params ) ) { + throw new ContainerException( "Cannot pass parameters to an already instantiated shared service: {$cached_key}" ); + } + $this->instances[$id] = $this->instances[$cached_key]; + return $this->instances[$id]; } - $constructor = $ref->getConstructor(); - $args = []; + // 5. Build the instance + $instance = $this->build( $concrete, $params, $id ); - if ( $constructor ) { - $args = $this->resolve_dependencies( $constructor, $params ); + // 6. Cache if shared + if ( $is_shared ) { + $this->instances[$id] = $instance; + + // Map the instance to the concrete class name too + if ( is_string( $concrete ) && $concrete !== $id ) { + $this->instances[$concrete] = $instance; + } + + // Map it to the terminal resolved ID too + if ( $resolved_id !== $id && $resolved_id !== $concrete ) { + $this->instances[$resolved_id] = $instance; + } } - return $ref->newInstanceArgs( $args ); + return $instance; } finally { unset( $this->resolving[$id] ); } } /** - * Set a service instance directly. + * Create a new instance of the given class (Factory). * - * @param string $id - * @param mixed $service - * @return void + * @param string $abstract + * @param array $parameters + * @return mixed + * @throws ContainerException + * @throws NotFoundException */ - public function set( string $id, $service ): void { - $this->instances[$id] = $service; + public function make( string $abstract, array $parameters = [] ) { + $abstract = $this->registry->resolve_id( $abstract ); + $concrete = $this->registry->get_concrete( $abstract ); + + return $this->build( $concrete, $parameters, $abstract ); } /** - * Check if container has a service (either instance or class exists) + * Build the concrete instance. * - * @param string $id - * @return bool + * @param mixed $concrete + * @param array $params + * @param ?string $id + * @return mixed + * @throws ContainerException If the target is not instantiable. + * @throws NotFoundException If class or alias not found. */ - public function has( string $id ): bool { - return isset( $this->instances[$id] ) || class_exists( $id ); + protected function build( $concrete, array $params = [], ?string $id = null ) { + // Resolve Closure closures + if ( $concrete instanceof Closure ) { + return $concrete( $this, $params ); + } + + if ( is_string( $concrete ) ) { + // Try to autowire the class + if ( class_exists( $concrete ) ) { + return $this->engine->resolve( $concrete, $params ); + } + + // Return raw string if it's an alias pointing to something else + if ( $id !== null && $concrete !== $id ) { + return $concrete; + } + + throw new NotFoundException( "Class or alias not found: {$concrete}" ); + } + + // Return provided objects + if ( is_object( $concrete ) ) { + return $concrete; + } + + // Handle array callbacks + if ( is_array( $concrete ) && is_callable( $concrete ) ) { + return $this->call( $concrete, $params ); + } + + // Return scalars/arrays + if ( is_scalar( $concrete ) || is_array( $concrete ) ) { + return $concrete; + } + + throw new ContainerException( "Target is not instantiable or callable: " . gettype( $concrete ) ); } /** - * Create a new instance of the given class (Factory). - * Does not store the instance as a singleton. + * Call a callback with dependency injection. * - * @param string $abstract - * @param array $parameters + * @param callable|array|string $callback + * @param array $parameters * @return mixed + * @throws \ReflectionException * @throws ContainerException - * @throws NotFoundException - * @throws \Exception */ - public function make( string $abstract, array $parameters = [] ) { - return $this->resolve( $abstract, $parameters ); + public function call( $callback, array $parameters = [] ) { + return $this->invoker->call( $callback, $parameters ); } /** - * Call a callback with dependency injection. + * Set a shared instance (singleton) directly into the container. * - * @param callable|array|string $callback - * @param array $parameters - * @return mixed - * @throws \ReflectionException + * @param string $id + * @param mixed $instance + * @return $this */ - public function call( $callback, array $parameters = [] ) { - if ( is_array( $callback ) ) { - $class = is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0]; - $method = $callback[1]; - $ref = new \ReflectionMethod( $class, $method ); + public function set( string $id, $instance ): self { + $resolved_id = $this->registry->resolve_id( $id ); + $concrete = $this->registry->get_concrete( $id ); - if ( is_string( $callback[0] ) && ! $ref->isStatic() ) { - $callback[0] = $this->get( $callback[0] ); - } - } elseif ( is_string( $callback ) && strpos( $callback, '::' ) !== false ) { - $parts = explode( '::', $callback ); - $ref = new \ReflectionMethod( $parts[0], $parts[1] ); + $this->instances[$id] = $instance; - if ( ! $ref->isStatic() ) { - $instance = $this->get( $parts[0] ); - $callback = [$instance, $parts[1]]; - } - } elseif ( is_object( $callback ) && ! ( $callback instanceof \Closure ) ) { - $ref = new \ReflectionMethod( $callback, '__invoke' ); - } else { - $ref = new \ReflectionFunction( $callback ); + if ( is_string( $concrete ) && $concrete !== $id ) { + $this->instances[$concrete] = $instance; } - $args = $this->resolve_dependencies( $ref, $parameters ); + if ( $resolved_id !== $id && $resolved_id !== $concrete ) { + $this->instances[$resolved_id] = $instance; + } - return call_user_func_array( $callback, $args ); + return $this; } /** - * Resolve dependencies for a reflection function/method. - * - * @param \ReflectionFunctionAbstract $ref - * @param array $parameters - * @return array + * Check if the container has a service or class registered. + * + * @param string $id + * @return bool */ - protected function resolve_dependencies( \ReflectionFunctionAbstract $ref, array $parameters = [] ): array { - $args = []; - foreach ( $ref->getParameters() as $param ) { - $name = $param->getName(); - - if ( array_key_exists( $name, $parameters ) ) { - $args[] = $parameters[$name]; - continue; - } + public function has( string $id ): bool { + // Fast paths: Already instantiated or registered in the registry + if ( isset( $this->instances[$id] ) || $this->registry->has( $id ) ) { + return true; + } - $type = $param->getType(); - if ( $type instanceof ReflectionNamedType && ! $type->isBuiltin() ) { - $id = $type->getName(); + // Slow path: Resolve alias and check again, then check class_exists + $id = $this->registry->resolve_id( $id ); + return isset( $this->instances[$id] ) || $this->registry->has( $id ) || class_exists( $id ); + } - // Avoid infinite recursion / simple circular dependency check could be added here - // For now, we trust get() to handle it (standard recursion) - $args[] = $this->get( $id ); - continue; - } + /** + * Get the terminal resolved ID for a given identifier. + * + * @param string $id + * @return string + */ + public function resolved_id( string $id ): string { + return $this->registry->resolve_id( $id ); + } - if ( $param->isDefaultValueAvailable() ) { - $args[] = $param->getDefaultValue(); - continue; - } + /** + * Clear all cached singleton instances. + * + * @return void + */ + public function forget_instances(): void { + $this->instances = []; + } - // If we cannot resolve it, pass null or throw? - // PHP will throw ArgumentCountError if we don't pass anything for a required param. - // We'll let that happen as it's the most correct behavior for missing dependencies. - } - return $args; + /** + * Reset the entire container to its initial state. + * + * @return void + */ + public function flush(): void { + $this->instances = []; + $this->registry->flush(); } } diff --git a/src/Exception/CircularDependencyException.php b/src/Exception/CircularDependencyException.php new file mode 100644 index 0000000..0a5e9d6 --- /dev/null +++ b/src/Exception/CircularDependencyException.php @@ -0,0 +1,23 @@ +bindings[$abstract] = [ + 'concrete' => $concrete ?: $abstract, + 'shared' => false, + ]; + } + + /** + * Register a shared (singleton) binding. + * + * @param string $abstract + * @param mixed|null $concrete + * @return void + */ + public function singleton( string $abstract, $concrete = null ): void { + $this->bindings[$abstract] = [ + 'concrete' => $concrete ?: $abstract, + 'shared' => true, + ]; + } + + /** + * Alias a service to a different name. + * + * @param string $abstract + * @param string $alias + * @return void + */ + public function alias( string $abstract, string $alias ): void { + $this->aliases[$alias] = $abstract; + } + + /** + * Assign a set of tags to a given binding. + * + * @param array|string $abstracts + * @param array|mixed $tags + * @return void + */ + public function tag( $abstracts, $tags ): void { + $tags = (array) $tags; + $abstracts = (array) $abstracts; + + foreach ( $tags as $tag ) { + if ( ! isset( $this->tags[$tag] ) ) { + $this->tags[$tag] = []; + } + + foreach ( $abstracts as $abstract ) { + $this->tags[$tag][] = $abstract; + } + } + } + + /** + * Get all bindings associated with a given tag. + * + * @param string $tag + * @return array + */ + public function get_tag( string $tag ): array { + return $this->tags[$tag] ?? []; + } + + /** + * Resolve the terminal identifier by following all aliases. + * + * @param string $id + * @return string + * @throws ContainerException If a circular alias resolution is detected. + */ + public function resolve_id( string $id ): string { + $history = []; + + while ( isset( $this->aliases[$id] ) ) { + if ( isset( $history[$id] ) ) { + throw new ContainerException( "Circular alias resolution detected for [{$id}]." ); + } + + $history[$id] = true; + $id = $this->aliases[$id]; + } + + return $id; + } + + /** + * Get the concrete implementation mapped to an abstract identifier. + * + * @param string $abstract + * @param array $history + * @return mixed + * @throws ContainerException If a circular resolution path is detected. + */ + public function get_concrete( string $abstract, array $history = [] ) { + if ( isset( $history[$abstract] ) ) { + throw new ContainerException( "Circular resolution detected for [{$abstract}]." ); + } + + $history[$abstract] = true; + + $id = $this->resolve_id( $abstract ); + + if ( isset( $this->bindings[$id] ) ) { + $concrete = $this->bindings[$id]['concrete']; + + // Recurse if the concrete is another binding or alias + if ( is_string( $concrete ) && $concrete !== $id && $this->has( $concrete ) ) { + return $this->get_concrete( $concrete, $history ); + } + + return $concrete; + } + + return $id; + } + + /** + * Determine if a given service is shared (singleton). + * + * @param string $abstract + * @return bool + */ + public function is_shared( string $abstract ): bool { + $id = $this->resolve_id( $abstract ); + return $this->bindings[$id]['shared'] ?? false; + } + + /** + * Check if a binding or alias exists for the given ID. + * + * @param string $id + * @return bool + */ + public function has( string $id ): bool { + return isset( $this->bindings[$id] ) || isset( $this->aliases[$id] ); + } + + /** + * Clear all bindings, aliases, and tags from the registry. + * + * @return void + */ + public function flush(): void { + $this->bindings = []; + $this->aliases = []; + $this->tags = []; + } +} diff --git a/src/ResolutionEngine.php b/src/ResolutionEngine.php new file mode 100644 index 0000000..72e7e8d --- /dev/null +++ b/src/ResolutionEngine.php @@ -0,0 +1,243 @@ + [], + 'methods' => [], + ]; + + /** + * ResolutionEngine constructor. + * + * @param ContainerInterface $container + */ + public function __construct( ContainerInterface $container ) { + $this->container = $container; + } + + /** + * Resolve a service instance using Reflection. + * + * @param string $id + * @param array $params + * @return mixed + * @throws NotFoundException If the class does not exist. + * @throws ContainerException If the class is not instantiable. + */ + public function resolve( string $id, array $params = [] ) { + if ( ! isset( $this->reflection_cache['constructors'][$id] ) ) { + $ref = new ReflectionClass( $id ); + + if ( ! $ref->isInstantiable() ) { + throw new ContainerException( "Class is not instantiable: {$id}" ); + } + + $this->reflection_cache['constructors'][$id] = $ref; + } + + $ref = $this->reflection_cache['constructors'][$id]; + $constructor = $ref->getConstructor(); + $args = []; + + if ( $constructor ) { + $args = $this->resolve_dependencies( $constructor->getParameters(), $params ); + } + + return $ref->newInstanceArgs( $args ); + } + + /** + * Resolve dependencies for a set of ReflectionParameters. + * + * @param \ReflectionParameter[] $parameters_metadata + * @param array $parameters + * @return array + * @throws ContainerException If a dependency cannot be resolved. + */ + public function resolve_dependencies( array $parameters_metadata, array $parameters = [] ): array { + $args = []; + + foreach ( $parameters_metadata as $param ) { + $name = $param->getName(); + + // 1. Try named parameter from the provided array + if ( array_key_exists( $name, $parameters ) ) { + $args[] = $parameters[$name]; + unset( $parameters[$name] ); + continue; + } + + // 2. Try to resolve by type hint + $type = $param->getType(); + if ( $type instanceof ReflectionNamedType && ! $type->isBuiltin() ) { + $id = $type->getName(); + + // Optimization: Check if any provided parameter matches this type + foreach ( $parameters as $key => $provided_param ) { + if ( is_object( $provided_param ) && ( $provided_param instanceof $id || ltrim( get_class( $provided_param ), '\\' ) === ltrim( $id, '\\' ) ) ) { + $args[] = $provided_param; + unset( $parameters[$key] ); + continue 2; + } + } + + try { + // Recursively resolve the dependency from the container + $args[] = $this->container->get( $id, $parameters ); + continue; + } catch ( NotFoundException $e ) { + // Fall through to other resolution strategies + } catch ( CircularDependencyException $e ) { + throw $e; + } catch ( ContainerException $e ) { + // Fall through to other resolution strategies + } + } + + // 3. Try variadic parameter + if ( $param->isVariadic() ) { + // Collect all remaining parameters + foreach ( $parameters as $key => $value ) { + $args[] = $value; + unset( $parameters[$key] ); + } + break; + } + + // 4. Try positional parameter from the remaining array + if ( ( $found_key = $this->resolve_positional_parameter( $parameters, $type ) ) !== null ) { + $args[] = $parameters[$found_key]; + unset( $parameters[$found_key] ); + continue; + } + + // 5. Try default value + if ( $param->isDefaultValueAvailable() ) { + $args[] = $param->getDefaultValue(); + continue; + } + + // 6. Handle Nullable types + if ( ( method_exists( $param, 'allowsNull' ) && $param->allowsNull() ) || ( $type && $type->allowsNull() ) ) { + $args[] = null; + continue; + } + + throw new ContainerException( "Unresolvable dependency [{$param}] in [{$param->getDeclaringClass()->getName()}]." ); + } + + return $args; + } + + /** + * Resolve a positional parameter from the given candidates. + * + * @param array $parameters + * @param \ReflectionType|null $type + * @return int|string|null + */ + protected function resolve_positional_parameter( array $parameters, $type ) { + $candidates = array_filter( array_keys( $parameters ), 'is_int' ); + sort( $candidates ); + + foreach ( $candidates as $key ) { + if ( $this->is_valid_type( $parameters[$key], $type ) ) { + return $key; + } + } + + return null; + } + + /** + * Check if a given value matches the expected reflection type. + * + * @param mixed $value + * @param \ReflectionType|null $type + * @return bool + */ + protected function is_valid_type( $value, $type ): bool { + if ( ! $type instanceof ReflectionNamedType ) { + return true; + } + + if ( ! $type->isBuiltin() ) { + $target = $type->getName(); + return $value instanceof $target; + } + + // Tighten scalar type checks + switch ( $type->getName() ) { + case 'int': return is_int( $value ); + case 'string': return is_string( $value ); + case 'bool': return is_bool( $value ); + case 'float': return is_float( $value ); + case 'array': return is_array( $value ); + case 'object': return is_object( $value ); + case 'callable': return is_callable( $value ); + case 'iterable': return is_iterable( $value ); + case 'mixed': return true; + } + + return ! is_object( $value ); + } + + /** + * Retrieve a cached reflection method. + * + * @param string $key + * @return \ReflectionMethod|null + */ + public function get_cached_method( string $key ): ?\ReflectionMethod { + return $this->reflection_cache['methods'][$key] ?? null; + } + + /** + * Cache a reflection method for future reuse. + * + * @param string $key + * @param \ReflectionMethod $method + * @return void + */ + public function cache_method( string $key, \ReflectionMethod $method ): void { + $this->reflection_cache['methods'][$key] = $method; + } +} diff --git a/tests/Fixtures/Contracts/InterfaceA.php b/tests/Fixtures/Contracts/InterfaceA.php new file mode 100644 index 0000000..343e0b6 --- /dev/null +++ b/tests/Fixtures/Contracts/InterfaceA.php @@ -0,0 +1,3 @@ +value = $value; + } +} diff --git a/tests/Fixtures/Models/ClassWithParams.php b/tests/Fixtures/Models/ClassWithParams.php new file mode 100644 index 0000000..810c738 --- /dev/null +++ b/tests/Fixtures/Models/ClassWithParams.php @@ -0,0 +1,14 @@ +value = $value; + $this->number = $number; + } +} diff --git a/tests/Fixtures/Models/ConcreteC.php b/tests/Fixtures/Models/ConcreteC.php new file mode 100644 index 0000000..ecd11df --- /dev/null +++ b/tests/Fixtures/Models/ConcreteC.php @@ -0,0 +1,4 @@ + $concrete, 'param' => $param]; + } +} diff --git a/tests/Fixtures/Models/Level1.php b/tests/Fixtures/Models/Level1.php new file mode 100644 index 0000000..0393ed0 --- /dev/null +++ b/tests/Fixtures/Models/Level1.php @@ -0,0 +1,8 @@ +level2 = $level2; } +} diff --git a/tests/Fixtures/Models/Level2.php b/tests/Fixtures/Models/Level2.php new file mode 100644 index 0000000..8a9136d --- /dev/null +++ b/tests/Fixtures/Models/Level2.php @@ -0,0 +1,8 @@ +level3 = $level3; } +} diff --git a/tests/Fixtures/Models/Level3.php b/tests/Fixtures/Models/Level3.php new file mode 100644 index 0000000..25d7fe0 --- /dev/null +++ b/tests/Fixtures/Models/Level3.php @@ -0,0 +1,8 @@ +level4 = $level4; } +} diff --git a/tests/Fixtures/Models/Level4.php b/tests/Fixtures/Models/Level4.php new file mode 100644 index 0000000..c5065d6 --- /dev/null +++ b/tests/Fixtures/Models/Level4.php @@ -0,0 +1,8 @@ +level5 = $level5; } +} diff --git a/tests/Fixtures/Models/Level5.php b/tests/Fixtures/Models/Level5.php new file mode 100644 index 0000000..1ac41ad --- /dev/null +++ b/tests/Fixtures/Models/Level5.php @@ -0,0 +1,3 @@ + $dependency, 'param' => $param]; + } + + public static function static_method( ConcreteClass $dependency, $param ) { + return ['dependency' => $dependency, 'param' => $param]; + } +} diff --git a/tests/Fixtures/Models/MixedDependencyClass.php b/tests/Fixtures/Models/MixedDependencyClass.php new file mode 100644 index 0000000..2c530ea --- /dev/null +++ b/tests/Fixtures/Models/MixedDependencyClass.php @@ -0,0 +1,12 @@ +concrete = $concrete; + $this->value = $value; + } +} diff --git a/tests/Fixtures/Models/NullablePrecClass.php b/tests/Fixtures/Models/NullablePrecClass.php new file mode 100644 index 0000000..d8befed --- /dev/null +++ b/tests/Fixtures/Models/NullablePrecClass.php @@ -0,0 +1,10 @@ +dep = $dep; + } +} diff --git a/tests/Fixtures/Models/ServiceWithDependency.php b/tests/Fixtures/Models/ServiceWithDependency.php new file mode 100644 index 0000000..4d9d45b --- /dev/null +++ b/tests/Fixtures/Models/ServiceWithDependency.php @@ -0,0 +1,11 @@ +dependency = $dependency; + } +} diff --git a/tests/Fixtures/Models/ServiceWithNestedDependency.php b/tests/Fixtures/Models/ServiceWithNestedDependency.php new file mode 100644 index 0000000..5c32e79 --- /dev/null +++ b/tests/Fixtures/Models/ServiceWithNestedDependency.php @@ -0,0 +1,11 @@ +service = $service; + } +} diff --git a/tests/Fixtures/Models/VariadicClass.php b/tests/Fixtures/Models/VariadicClass.php new file mode 100644 index 0000000..96813f2 --- /dev/null +++ b/tests/Fixtures/Models/VariadicClass.php @@ -0,0 +1,10 @@ +args = $args; + } +} diff --git a/tests/Fixtures/Models/VisibilityClass.php b/tests/Fixtures/Models/VisibilityClass.php new file mode 100644 index 0000000..49c12ed --- /dev/null +++ b/tests/Fixtures/Models/VisibilityClass.php @@ -0,0 +1,13 @@ +container = new Container(); + } + + /** + * Verifies resolution of deep dependency trees (5 levels). + */ + public function test_it_resolves_deep_dependency_trees() { + $start_time = microtime( true ); + $level1 = $this->container->get( Level1::class ); + $end_time = microtime( true ); + + $this->assertInstanceOf( Level1::class, $level1 ); + $this->assertInstanceOf( Level2::class, $level1->level2 ); + $this->assertInstanceOf( Level3::class, $level1->level2->level3 ); + $this->assertInstanceOf( Level4::class, $level1->level2->level3->level4 ); + $this->assertInstanceOf( Level5::class, $level1->level2->level3->level4->level5 ); + + // Performance check for reflection cache: second resolution should be significantly faster + $start_time2 = microtime( true ); + $this->container->make( Level1::class ); + $end_time2 = microtime( true ); + + $this->assertLessThanOrEqual( $end_time - $start_time, $end_time2 - $start_time2, "Cached resolution should be faster." ); + } + + /** + * Verifies resolution of interface chains (A -> B -> Concrete). + */ + public function test_it_resolves_interface_chains() { + $this->container->bind( InterfaceA::class, InterfaceB::class ); + $this->container->bind( InterfaceB::class, ConcreteC::class ); + + $instance = $this->container->get( InterfaceA::class ); + $this->assertInstanceOf( ConcreteC::class, $instance ); + } + + /** + * Verifies resolution of recursive alias chains. + */ + public function test_it_resolves_recursive_aliases() { + $this->container->alias( ConcreteClass::class, 'alias3' ); + $this->container->alias( 'alias3', 'alias2' ); + $this->container->alias( 'alias2', 'alias1' ); + + $instance = $this->container->get( 'alias1' ); + $this->assertInstanceOf( ConcreteClass::class, $instance ); + } + + /** + * Verifies handling of mixed autowiring (classes + provided parameters). + */ + public function test_it_handles_mixed_autowiring_with_overrides() { + $instance = $this->container->make( + MixedDependencyClass::class, [ + 'value' => 'injected-value' + ] + ); + + $this->assertInstanceOf( MixedDependencyClass::class, $instance ); + $this->assertInstanceOf( ConcreteClass::class, $instance->concrete ); + $this->assertEquals( 'injected-value', $instance->value ); + } +} diff --git a/tests/Integration/CircularDependencyTest.php b/tests/Integration/CircularDependencyTest.php new file mode 100644 index 0000000..3943947 --- /dev/null +++ b/tests/Integration/CircularDependencyTest.php @@ -0,0 +1,139 @@ +singleton( StubInterface::class, StubConcrete::class ); + + // Resolve concrete FIRST + $instance1 = $container->get( StubConcrete::class ); + // Resolve abstract SECOND + $instance2 = $container->get( StubInterface::class ); + + $this->assertSame( $instance1, $instance2, "Concrete and Abstract must resolve to the SAME singleton instance." ); + } + + /** + * Verifies that the CallbackInvoker can resolve aliased services. + */ + public function test_callback_invoker_resolves_aliases() { + $container = new Container(); + $container->singleton( StubConcrete::class ); + $container->alias( StubConcrete::class, 'my_api' ); + + $result = $container->call( 'my_api::testMethod' ); + $this->assertEquals( 'ok', $result ); + } + + /** + * Verifies that circular dependencies throw the specific CircularDependencyException. + */ + public function test_circular_dependency_uses_specific_exception() { + $container = new Container(); + + $this->expectException( CircularDependencyException::class ); + $container->get( CircA::class ); + } + + /** + * Verifies that setting a service with an alias synchronizes correctly with the real class. + */ + public function test_set_with_alias_is_synchronized_with_real_class() { + $container = new Container(); + $container->alias( StubConcrete::class, 'my_service' ); + + $manual = new StubConcrete(); + $manual->val = 'manual'; + + $container->set( 'my_service', $manual ); + + $resolved = $container->get( StubConcrete::class ); + $this->assertSame( $manual, $resolved ); + $this->assertEquals( 'manual', $resolved->val ); + } + + /** + * Verifies that positional parameters do not collide with type hints during resolution. + */ + public function test_positional_parameters_do_not_collide_with_type_hints() { + $container = new Container(); + + $instance = $container->make( TargetWithMixedParams::class, ['SomeName'] ); + + $this->assertInstanceOf( StubConcrete::class, $instance->dep ); + $this->assertEquals( 'SomeName', $instance->name ); + } + + /** + * Verifies that variadic parameters correctly collect remaining named arguments. + */ + public function test_variadic_parameters_collect_remaining_named_args() { + $container = new Container(); + $instance = $container->make( VariadicTarget::class, ['first' => 1, 'second' => 2] ); + + $this->assertEquals( [1, 2], array_values( $instance->args ) ); + } + + /** + * Verifies that the reflection cache is isolated per container instance. + */ + public function test_reflection_cache_is_isolated_per_container() { + $c1 = new Container(); + $c2 = new Container(); + + $this->assertInstanceOf( StubConcrete::class, $c1->get( StubConcrete::class ) ); + $this->assertInstanceOf( StubConcrete::class, $c2->get( StubConcrete::class ) ); + } +} + +// Internal Stubs for Testing CircularDependencyTest +interface StubInterface {} +class StubConcrete implements StubInterface { + public $val = 'real'; + + public function testMethod() { + return 'ok'; } +} +class CircA { public function __construct( CircB $b ) {} } +class CircB { public function __construct( CircA $a ) {} } +class TargetWithMixedParams { + public $dep; + + public $name; + + public function __construct( StubConcrete $dep, $name ) { + $this->dep = $dep; + $this->name = $name; + } +} +class VariadicTarget { + public $args; + + public function __construct( ...$args ) { + $this->args = $args; } +} diff --git a/tests/Integration/ContainerLifecycleTest.php b/tests/Integration/ContainerLifecycleTest.php new file mode 100644 index 0000000..3072151 --- /dev/null +++ b/tests/Integration/ContainerLifecycleTest.php @@ -0,0 +1,110 @@ +container = new Container(); + } + + /** + * Verifies the distinction between singleton and transient scopes. + */ + public function test_it_differentiates_between_singleton_and_transient_scopes() { + // Singleton scope + $this->container->singleton( 'shared', ConcreteClass::class ); + $obj1 = $this->container->get( 'shared' ); + $obj2 = $this->container->get( 'shared' ); + $this->assertSame( $obj1, $obj2, "Singleton must return the same instance." ); + + // Transient scope + $this->container->bind( 'transient', ConcreteClass::class ); + $obj3 = $this->container->get( 'transient' ); + $obj4 = $this->container->get( 'transient' ); + $this->assertNotSame( $obj3, $obj4, "Bind must return a fresh instance every time." ); + } + + /** + * Verifies that parameters can be overridden during make() calls. + */ + public function test_it_overrides_parameters_in_make() { + $instance = $this->container->make( + ClassWithParams::class, [ + 'value' => 'runtime-value', + 'number' => 100 + ] + ); + + $this->assertEquals( 'runtime-value', $instance->value ); + $this->assertEquals( 100, $instance->number ); + + // Different call with partial overrides + $instance2 = $this->container->make( + ClassWithParams::class, [ + 'value' => 'another-value' + ] + ); + $this->assertEquals( 'another-value', $instance2->value ); + $this->assertEquals( 0, $instance2->number ); // Should use default 0 + } + + /** + * Verifies that shared instances cannot be parameterized after instantiation. + */ + public function test_it_guards_shared_instance_parameterization() { + $this->container->singleton( ClassWithParams::class ); + + $instance = $this->container->get( ClassWithParams::class, ['value' => 'first-call'] ); + $this->assertEquals( 'first-call', $instance->value ); + + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'Cannot pass parameters to an already instantiated shared service' ); + + $this->container->get( ClassWithParams::class, ['value' => 'second-call'] ); + } + + /** + * Verifies that closure factories receive the container and specific parameters. + */ + public function test_it_passes_parameters_to_closure_factories() { + $this->container->bind( + 'factory', function( $app, $params ) { + $obj = new \stdClass(); + $obj->injected = $params['val'] ?? 'default'; + $obj->app = $app; + return $obj; + } + ); + + $instance = $this->container->make( 'factory', ['val' => 'custom'] ); + $this->assertEquals( 'custom', $instance->injected ); + $this->assertSame( $this->container, $instance->app ); + + $instance2 = $this->container->make( 'factory' ); + $this->assertEquals( 'default', $instance2->injected ); + } +} diff --git a/tests/Integration/ContainerRobustnessTest.php b/tests/Integration/ContainerRobustnessTest.php new file mode 100644 index 0000000..80197eb --- /dev/null +++ b/tests/Integration/ContainerRobustnessTest.php @@ -0,0 +1,165 @@ +container = new Container(); + } + + /** + * Verifies support for object bindings (injecting an already instantiated object). + */ + public function test_it_supports_object_bindings() { + $object = new \stdClass(); + $object->val = 'pre-built'; + + $this->container->singleton( 'service', $object ); + + $this->assertSame( $object, $this->container->get( 'service' ) ); + $this->assertEquals( 'pre-built', $this->container->get( 'service' )->val ); + } + + /** + * Verifies handling of variadic parameters in class constructors. + */ + public function test_it_handles_variadic_constructor_params() { + // Empty variadics + $instance = $this->container->make( VariadicClass::class ); + $this->assertEmpty( $instance->args ); + + // Passed positional params + $instance2 = $this->container->make( VariadicClass::class, ['a', 'b', 'c'] ); + $this->assertCount( 3, $instance2->args ); + $this->assertEquals( ['a', 'b', 'c'], $instance2->args ); + } + + /** + * Verifies that the container can call invokable objects. + */ + public function test_it_resolves_invokable_objects() { + $invokable = new InvokableClass(); + $result = $this->container->call( $invokable, ['param' => 'hello'] ); + + $this->assertInstanceOf( ConcreteClass::class, $result['concrete'] ); + $this->assertEquals( 'hello', $result['param'] ); + } + + /** + * Verifies detection of alias resolution loops (A -> B -> A). + */ + public function test_it_detects_alias_loops() { + $this->container->alias( 'serviceB', 'serviceA' ); + $this->container->alias( 'serviceA', 'serviceB' ); + + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'Circular alias resolution' ); + + $this->container->get( 'serviceA' ); + } + + /** + * Verifies that instances can be cleared from the container. + */ + public function test_it_can_forget_instances() { + $this->container->singleton( 's1', ConcreteClass::class ); + $obj1 = $this->container->get( 's1' ); + + $this->container->forget_instances(); + + $obj2 = $this->container->get( 's1' ); + $this->assertNotSame( $obj1, $obj2, "After forgetting instances, singleton should be re-instantiated." ); + } + + /** + * Verifies the ability to flush the entire container registry and instance cache. + */ + public function test_it_can_flush_everything() { + $this->container->singleton( 's1', ConcreteClass::class ); + $this->container->alias( 's1', 'alias1' ); + + $this->container->flush(); + + $this->assertFalse( $this->container->has( 's1' ) ); + $this->assertFalse( $this->container->has( 'alias1' ) ); + } + + /** + * Verifies that optional/nullable parameters resolve to null when dependencies are missing. + */ + public function test_it_prefers_default_values_over_null_for_nullable_params() { + $result = $this->container->call( + function( ?NonExistentInterfaceForTest $dep = null ) { + return $dep === null ? 'is-null' : 'is-not-null'; + } + ); + + $this->assertEquals( 'is-null', $result ); + } + + /** + * Verifies that the container strictly respects method visibility (public vs private). + */ + public function test_it_strictly_respects_method_visibility() { + $instance = new VisibilityClass(); + + // Public should work + $this->assertEquals( 'public', $this->container->call( [$instance, 'public_method'] ) ); + + // Private should fail + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'is not public' ); + $this->container->call( [$instance, 'private_method'] ); + } + + /** + * Verifies support for static method resolution. + */ + public function test_it_resolves_static_methods() { + $result = $this->container->call( 'WpMVC\Container\Tests\Fixtures\Models\MethodInjectionClass::static_method', ['param' => 'static'] ); + $this->assertEquals( 'static', $result['param'] ); + $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); + } + + /** + * Verifies support for primitive value bindings (strings, arrays, bools). + */ + public function test_it_supports_primitive_value_bindings() { + $this->container->singleton( 'api_key', 'secret-123' ); + $this->container->bind( 'config_array', ['db' => 'localhost'] ); + $this->container->singleton( 'is_enabled', true ); + + $this->assertEquals( 'secret-123', $this->container->get( 'api_key' ) ); + $this->assertEquals( ['db' => 'localhost'], $this->container->get( 'config_array' ) ); + $this->assertTrue( $this->container->get( 'is_enabled' ) ); + } +} + +// Internal Stub for Nullable Check +interface NonExistentInterfaceForTest {} diff --git a/tests/Integration/ExtensionTaggingTest.php b/tests/Integration/ExtensionTaggingTest.php new file mode 100644 index 0000000..aa7e631 --- /dev/null +++ b/tests/Integration/ExtensionTaggingTest.php @@ -0,0 +1,89 @@ +container = new Container(); + } + + /** + * Verifies basic tagging and retrieval of multiple services. + */ + public function test_it_can_tag_and_retrieve_services() { + $this->container->singleton( 'service1', ConcreteClass::class ); + $this->container->singleton( 'service2', ConcreteClass::class ); + + $this->container->tag( ['service1', 'service2'], 'test_tag' ); + + $tagged = $this->container->tagged( 'test_tag' ); + + $this->assertCount( 2, $tagged ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[0] ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[1] ); + } + + /** + * Verifies that a single service can be assigned multiple tags. + */ + public function test_it_supports_multi_tagging() { + $this->container->singleton( 'service', ConcreteClass::class ); + $this->container->tag( 'service', ['tag1', 'tag2'] ); + + $this->assertCount( 1, $this->container->tagged( 'tag1' ) ); + $this->assertCount( 1, $this->container->tagged( 'tag2' ) ); + + $this->assertInstanceOf( ConcreteClass::class, $this->container->tagged( 'tag1' )[0] ); + } + + /** + * Verifies that tagged services have their dependencies correctly autowired upon retrieval. + */ + public function test_it_resolves_complex_services_via_tags() { + $this->container->singleton( 'complex', ServiceWithDependency::class ); + $this->container->tag( 'complex', 'app.services' ); + + $services = $this->container->tagged( 'app.services' ); + + $this->assertInstanceOf( ServiceWithDependency::class, $services[0] ); + $this->assertInstanceOf( ConcreteClass::class, $services[0]->dependency ); + } + + /** + * Verifies that aliases can be used when tagging services. + */ + public function test_it_resolves_aliases_in_tags() { + $this->container->singleton( ConcreteClass::class ); + $this->container->alias( ConcreteClass::class, 'my_alias' ); + + $this->container->tag( 'my_alias', 'aliased_tag' ); + + $tagged = $this->container->tagged( 'aliased_tag' ); + + $this->assertCount( 1, $tagged ); + $this->assertInstanceOf( ConcreteClass::class, $tagged[0] ); + } +} diff --git a/tests/Integration/RecursiveResolutionTest.php b/tests/Integration/RecursiveResolutionTest.php new file mode 100644 index 0000000..6a59b93 --- /dev/null +++ b/tests/Integration/RecursiveResolutionTest.php @@ -0,0 +1,90 @@ +alias( $abstract, $interface ); + $container->bind( $abstract, $alias ); + $container->alias( $concrete, $alias ); + + $instance = $container->get( $interface ); + $this->assertInstanceOf( $concrete, $instance ); + } + + /** + * Verifies that the CallbackInvoker can resolve service IDs from the container. + */ + public function test_callback_invoker_resolves_generic_service_id() { + $container = new Container(); + $container->set( 'my_service', new CallbackService() ); + + $result = $container->call( ['my_service', 'handle'] ); + $this->assertEquals( 'handled', $result ); + } + + /** + * Verifies that the ResolutionEngine correctly filters positional candidates based on type hints. + */ + public function test_positional_parameters_respect_builtin_type_hints() { + $container = new Container(); + $container->singleton( ConcreteClass::class ); + + // Provides stdClass (invalid for string $name) and "John" (valid for string $name) + $instance = $container->make( PositionalGuardTarget::class, [new \stdClass(), "John"] ); + + $this->assertEquals( "John", $instance->name ); + $this->assertInstanceOf( ConcreteClass::class, $instance->dep ); + } +} + +// Internal Stubs for RecursiveResolutionTest +interface RecursiveInterface {} +class RecursiveImpl implements RecursiveInterface {} + +class CallbackService { + public function handle() { + return 'handled'; + } +} + +class PositionalGuardTarget { + public $name; + + public $dep; + + public function __construct( string $name, ConcreteClass $dep ) { + $this->name = $name; + $this->dep = $dep; + } +} diff --git a/tests/Integration/TypeSafetyTest.php b/tests/Integration/TypeSafetyTest.php new file mode 100644 index 0000000..ed8cf1c --- /dev/null +++ b/tests/Integration/TypeSafetyTest.php @@ -0,0 +1,80 @@ +bind( + 'A', function( $c ) { + return $c->get( 'B' ); + } + ); + + $container->bind( + 'B', function( $c ) { + return $c->get( 'A' ); + } + ); + + try { + $container->get( 'A' ); + } catch ( \Exception $e ) { + $this->assertInstanceOf( CircularDependencyException::class, $e ); + return; + } + + $this->fail( 'CircularDependencyException was not thrown for closure cycle.' ); + } + + /** + * Verifies that scalar type guards prevent type mismatches during resolution. + */ + public function test_scalar_type_guards_prevent_mismatch() { + $container = new Container(); + + // Target needs an int + $target = new class(0) { + public $val; + + public function __construct( int $val ) { + $this->val = $val; } + }; + $class = get_class( $target ); + + // Providing a string "123" instead of an int should fail + try { + $container->make( $class, ["123"] ); + } catch ( \Exception $e ) { + $this->assertInstanceOf( ContainerException::class, $e ); + return; + } + + $this->fail( 'ContainerException was not thrown for scalar type mismatch.' ); + } +} diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 32aae0c..7d3587b 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -1,4 +1,11 @@ container = new Container(); } + /** + * Verifies that the container can be instantiated. + */ public function test_it_can_be_instantiated() { $this->assertInstanceOf( Container::class, $this->container ); } + /** + * Verifies resolution of a class that has no dependencies. + */ public function test_it_resolves_a_class_without_dependencies() { $instance = $this->container->get( ConcreteClass::class ); $this->assertInstanceOf( ConcreteClass::class, $instance ); } + /** + * Verifies that singletons return the same instance across multiple resolutions. + */ public function test_it_returns_same_instance_for_singleton() { $instance1 = $this->container->get( ConcreteClass::class ); $instance2 = $this->container->get( ConcreteClass::class ); @@ -31,6 +64,9 @@ public function test_it_returns_same_instance_for_singleton() { $this->assertSame( $instance1, $instance2 ); } + /** + * Verifies that make() always returns a new instance even for shared services. + */ public function test_make_returns_new_instance() { $instance1 = $this->container->make( ConcreteClass::class ); $instance2 = $this->container->make( ConcreteClass::class ); @@ -40,16 +76,16 @@ public function test_make_returns_new_instance() { $this->assertNotSame( $instance1, $instance2 ); } + /** + * Verifies that has() returns true for classes that exist. + */ public function test_has_returns_true_for_existing_classes() { $this->assertTrue( $this->container->has( ConcreteClass::class ) ); } - public function test_has_returns_false_for_non_existent_classes() { - // has() returns true if class_exists() is true. - // It should return false for a nonsense string. - $this->assertFalse( $this->container->has( 'NonExistentClassXYZ' ) ); - } - + /** + * Verifies automatic resolution of constructor dependencies. + */ public function test_it_resolves_dependencies_automatically() { $service = $this->container->get( ServiceWithDependency::class ); @@ -57,6 +93,9 @@ public function test_it_resolves_dependencies_automatically() { $this->assertInstanceOf( ConcreteClass::class, $service->dependency ); } + /** + * Verifies automatic resolution of multi-level nested dependencies. + */ public function test_it_resolves_nested_dependencies() { $nested = $this->container->get( ServiceWithNestedDependency::class ); @@ -65,11 +104,17 @@ public function test_it_resolves_nested_dependencies() { $this->assertInstanceOf( ConcreteClass::class, $nested->service->dependency ); } + /** + * Verifies that requesting a non-existent class throws a NotFoundException. + */ public function test_it_throws_not_found_exception_for_non_existent_class() { $this->expectException( NotFoundException::class ); $this->container->get( 'Some\Random\NonExistent\Class' ); } + /** + * Verifies that circular dependencies are caught (basic autowiring case). + */ public function test_it_throws_container_exception_for_circular_dependency() { $this->expectException( ContainerException::class ); $this->expectExceptionMessage( 'Circular dependency detected' ); @@ -77,6 +122,9 @@ public function test_it_throws_container_exception_for_circular_dependency() { $this->container->get( CircularA::class ); } + /** + * Verifies that make() can override default parameters. + */ public function test_it_can_make_with_parameters_override() { $instance = $this->container->make( ClassWithParams::class, @@ -90,6 +138,9 @@ public function test_it_can_make_with_parameters_override() { $this->assertEquals( 42, $instance->number ); } + /** + * Verifies method injection via the call() method. + */ public function test_call_method_injection() { $instance = new MethodInjectionClass(); @@ -104,6 +155,9 @@ public function test_call_method_injection() { $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); } + /** + * Verifies closure injection via the call() method. + */ public function test_call_closure_injection() { $result = $this->container->call( function ( ConcreteClass $dep, $test ) { @@ -116,74 +170,80 @@ function ( ConcreteClass $dep, $test ) { $this->assertEquals( 'worked', $result['test'] ); } - public function test_set_manually_binds_instance() { - $mock = new \stdClass(); - $this->container->set( 'custom_key', $mock ); + /** + * Verifies interface to concrete mapping. + */ + public function test_it_binds_interface_to_concrete() { + $this->container->bind( TestInterface::class, ConcreteImplementation::class ); + $instance = $this->container->get( TestInterface::class ); - $this->assertTrue( $this->container->has( 'custom_key' ) ); - $this->assertSame( $mock, $this->container->get( 'custom_key' ) ); + $this->assertInstanceOf( ConcreteImplementation::class, $instance ); } - public function test_call_static_method_injection() { - $result = $this->container->call( - [MethodInjectionClass::class, 'static_method'], - [ - 'param' => 'static_test' - ] + /** + * Verifies using a closure as a factory binding. + */ + public function test_it_binds_closure_as_factory() { + $this->container->bind( + 'config', function() { + return ['db' => 'localhost']; + } ); - $this->assertEquals( 'static_test', $result['param'] ); - $this->assertInstanceOf( ConcreteClass::class, $result['dependency'] ); + $config = $this->container->get( 'config' ); + $this->assertEquals( 'localhost', $config['db'] ); } -} -// Fixtures - -class ConcreteClass {} - -class ServiceWithDependency { - public $dependency; + /** + * Verifies the use of positional parameters in call(). + */ + public function test_call_with_positional_parameters() { + $result = $this->container->call( + function ( ConcreteClass $dep, $test ) { + return ['dep' => $dep, 'test' => $test]; + }, + [1 => 'positional'] // Index 1 is $test + ); - public function __construct( ConcreteClass $dependency ) { - $this->dependency = $dependency; + $this->assertInstanceOf( ConcreteClass::class, $result['dep'] ); + $this->assertEquals( 'positional', $result['test'] ); } -} - -class ServiceWithNestedDependency { - public $service; - public function __construct( ServiceWithDependency $service ) { - $this->service = $service; - } -} + /** + * Verifies handling of nullable parameters in call(). + */ + public function test_it_handles_nullable_parameters() { + $result = $this->container->call( + function ( ?TestInterface $dep = null ) { + return $dep; + } + ); -class CircularA { - public function __construct( CircularB $b ) { + $this->assertNull( $result ); } -} -class CircularB { - public function __construct( CircularA $a ) { + /** + * Verifies resolution of optional constructor parameters. + */ + public function test_it_resolves_optional_parameters() { + $instance = $this->container->make( ClassWithOptionalParam::class ); + $this->assertEquals( 'default', $instance->value ); } -} -class ClassWithParams { - public $value; + /** + * Verifies that middleware-style calls work correctly (injecting stdClass and closure). + */ + public function test_it_correctly_resolves_middleware_like_calls() { + $req = new \stdClass(); + $next = function() { return 'next'; }; - public $number; - - public function __construct( $value, $number = 0 ) { - $this->value = $value; - $this->number = $number; - } -} - -class MethodInjectionClass { - public function method( ConcreteClass $dependency, $param ) { - return ['dependency' => $dependency, 'param' => $param]; - } + $result = $this->container->call( + function( \stdClass $r, $n ) { + return [$r, $n()]; + }, [$req, $next] + ); - public static function static_method( ConcreteClass $dependency, $param ) { - return ['dependency' => $dependency, 'param' => $param]; + $this->assertSame( $req, $result[0] ); + $this->assertEquals( 'next', $result[1] ); } } From 1bf661e98df2cc752f8c36bbe99e2d4258a42135 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:45:57 +0600 Subject: [PATCH 2/5] Update .gitattributes --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitattributes b/.gitattributes index 64431fd..2cbb4fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,10 @@ * text=auto +/.github export-ignore +/bin export-ignore /tests export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore /phpunit.xml export-ignore +/phpcs.xml export-ignore +/README.md export-ignore \ No newline at end of file From b967b749d49da551a7ac51509324630d600f375a Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:54:03 +0600 Subject: [PATCH 3/5] Update Container.php --- src/Container.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Container.php b/src/Container.php index f6edbdf..9e81243 100644 --- a/src/Container.php +++ b/src/Container.php @@ -142,9 +142,11 @@ function ( $abstract ) { /** * Get a service from the container. * - * @param string $id - * @param array $params - * @return mixed + * @template T + * @param class-string|string $id Service ID or class name to resolve. + * @param array $params Parameters for resolution. + * @return T + * * @throws NotFoundException If the service cannot be resolved. * @throws CircularDependencyException If a circularity is detected. * @throws ContainerException If instantiation fails. @@ -216,9 +218,11 @@ public function get( string $id, array $params = [] ) { /** * Create a new instance of the given class (Factory). * - * @param string $abstract - * @param array $parameters - * @return mixed + * @template T + * @param class-string|string $abstract The class name or abstract to build. + * @param array $parameters Parameters for resolution. + * @return T + * * @throws ContainerException * @throws NotFoundException */ From 5220e6b59e8a97954ef9b603c453ad1ed4d2af00 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:28:23 +0600 Subject: [PATCH 4/5] feat(container): implement contextual binding support --- .gitignore | 1 + README.md | 195 ++++++++++++------- src/CallbackInvoker.php | 38 ++-- src/Container.php | 133 +++++++------ src/ContextualBindingBuilder.php | 81 ++++++++ src/Registry.php | 87 +++++++-- src/ResolutionEngine.php | 66 +++++-- tests/Integration/CircularDependencyTest.php | 1 + tests/Integration/ContainerLifecycleTest.php | 11 +- tests/Unit/ContainerTest.php | 55 ++++++ tests/Unit/ContextualBindingTest.php | 65 +++++++ 11 files changed, 561 insertions(+), 172 deletions(-) create mode 100644 src/ContextualBindingBuilder.php create mode 100644 tests/Unit/ContextualBindingTest.php diff --git a/.gitignore b/.gitignore index a7573f3..66732cb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ package-lock.json composer.lock .phpunit.result.cache phpunit-report.xml +phpcs-report.xml diff --git a/README.md b/README.md index 2dd1453..eeb0eda 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # WpMVC Container -A lightweight, powerful Dependency Injection (DI) Container designed for the WpMVC framework. It provides automatic dependency resolution, singleton management, and flexible method invocation capabilities. +A lightweight, powerful, and **PSR-11 compliant** Dependency Injection (DI) Container designed for the WpMVC framework. It provides automatic dependency resolution, singleton management, flexible method invocation, and advanced autowiring capabilities. ## Installation @@ -17,12 +17,14 @@ composer require wpmvc/container ## Features - **Zero Configuration**: Automatically resolves dependencies using PHP Reflection. -- **Singleton Pattern**: Maintains single instances of services via `get()`. -- **Factory Pattern**: Creates fresh instances on demand via `make()`. -- **Method Injection**: Supports dependency injection for method calls via `call()`. -- **Circular Dependency Detection**: Prevents infinite loops during resolution errors. -- **Parameter Overrides**: Allows passing specific parameters to constructors and methods. -- **PSR-4 Compatible**: designed for modern PHP applications. +- **Singleton by Default**: Maintains single instances of services unless bound otherwise. +- **Flexible Bindings**: Explicitly register transient, shared, or existing instances. +- **Aliasing & Tagging**: Group and reference services using flexible identifiers. +- **Advanced Autowiring**: Supports positional/variadic parameters, nullable types, and default values. +- **Method Injection**: Supports DI for method calls via `call()`, including static methods and invokables. +- **Circular Dependency Detection**: Prevents infinite loops with clear exception reporting. +- **Fluent Interface**: Supports method chaining for configuration. +- **PSR-11 Compatible**: Implements `Psr\Container\ContainerInterface`. ## Usage @@ -34,113 +36,172 @@ use WpMVC\Container; $container = new Container(); ``` -### Retrieving Services (Singleton) +### Service Registration -The `get` method retrieves a service. If the service has not been created yet, the container will attempt to instantiate it, resolving any dependencies automatically. Subsequent calls return the *same* instance. +By default, any class requested via `get()` is treated as a **singleton**. However, you can explicitly define how services are resolved. +#### Transient Bindings +Creates a fresh instance every time it is resolved. ```php -$service = $container->get(MyService::class); +$container->bind(MyService::class, MyService::class); ``` -### Creating New Instances (Factory) +#### Shared Bindings (Singletons) +Ensures only one instance exists within the container. +```php +$container->singleton(MyLogger::class, MyLogger::class); +``` -The `make` method always creates a *new* instance of the requested class, resolving dependencies afresh. +#### Closure Bindings +Use a closure for complex instantiation logic. The container is passed as the first argument. +```php +$container->bind(Mailer::class, function($container, $params) { + return new Mailer($container->get(Config::class), $params['transport'] ?? 'smtp'); +}); +``` +#### Interface Binding +Bind an interface to a concrete implementation. ```php -$freshInstance = $container->make(MyService::class); +$container->singleton(UserRepositoryInterface::class, MysqlUserRepository::class); + +// The container will automatically resolve the concrete when the interface is requested. +$repo = $container->get(UserRepositoryInterface::class); ``` -### Manual Binding +#### Array Callback Bindings +Bind an abstract to a static method or class method callback. +```php +$container->bind('api.client', [ApiClientFactory::class, 'create']); +``` -You can manually register an existing instance into the container. +#### Primitive Bindings +The container can store and retrieve raw scalars or arrays. +```php +$container->set('database.config', ['host' => 'localhost', 'user' => 'root']); +$container->set('app.version', '1.0.0'); +``` +#### Instance Binding +Register an already-instantiated object. ```php -$container->set(MyInterface::class, new MyConcreteImplementation()); +$container->set(Configuration::class, new Configuration(['debug' => true])); ``` -### Checking Availability +### Retrieving Services -Check if a service is available (either instantiated or the class exists). +The `get` method retrieves a service. Subsequent calls return the *same* instance for singletons. ```php -if ($container->has(MyService::class)) { - // ... -} +$service = $container->get(MyService::class); ``` -## Dependency Injection +> [!NOTE] +> **Tri-cache Behavior**: For shared services, the container caches the instance under the requested ID, the terminal resolved ID, and the concrete class name. This ensures consistent resolution regardless of how the service is accessed. -The container uses PHP's Reflection API to inspect class constructors. +> [!IMPORTANT] +> Passing parameters to `get()` for an already-instantiated shared service will throw a `ContainerException`. Use `make()` if you need a fresh instance with custom parameters. -### Automatic Injection +### Creating New Instances (Factory) -If your class looks like this: +The `make` method always creates a *new* instance, even if it was registered as a singleton. ```php -class Database { ... } +$freshInstance = $container->make(MyService::class, ['param' => 'value']); +``` -class UserRepository { - protected $db; - public function __construct(Database $db) { - $this->db = $db; - } +### Checking Availability (`has`) + +Check if a service is available. The `has()` method follows a "Slow Path" logic: +1. Check if already instantiated. +2. Check if manually registered in the registry. +3. Check if the terminal resolved ID exists as a class (`class_exists`). + +```php +if ($container->has(MyService::class)) { + // ... } ``` -Calling `$container->get(UserRepository::class)` will automatically: -1. Detect `Database` dependency. -2. Resolve `Database` via `$container->get(Database::class)`. -3. Instantiate `UserRepository` with the resolved `Database` instance. +### Aliasing & Tagging -### Primitive Parameters & Overrides +#### Aliasing +Give a service a shorter or more descriptive name. The container supports **recursive alias resolution**, meaning aliases can point to other aliases. +```php +$container->alias(UserRepository::class, 'repo.user'); +$container->alias('repo.user', 'users'); -You can pass an array of parameters to `get()` or `make()` to override dependencies or provide primitive values (strings, ints, etc.). Keys must match the constructor parameter names. +$repo = $container->get('users'); // Resolves to UserRepository +``` +#### Tagging +Group related services (e.g., for plugins or extensions). ```php -class ApiClient { - public function __construct(HttpClient $client, string $apiKey) { ... } -} +$container->tag([MyExtension::class, AnotherExtension::class], 'app.plugins'); -$client = $container->get(ApiClient::class, [ - 'apiKey' => 'secret_123', // Matches $apiKey - // 'client' => $mockClient // Optional: could also override the object dependency -]); +// Retrieve all tagged services +$plugins = $container->tagged('app.plugins'); // Returns iterable ``` -## Advanced Features +## Dependency Injection -### Method Invocation (`call`) +The container uses PHP's Reflection API to inspect constructors and automatically resolve dependencies. -The `call` method allows you to execute any callable (function, closure, object method) while automatically injecting its dependencies. +### Automatic Injection ```php -class ReportController { - public function generate(ReportService $service, $format = 'json') { - return $service->build($format); - } +class UserRepository { + public function __construct(Database $db) { ... } } -// Automatically resolves ReportService and injects it. -// $format uses the default value 'json' unless overridden in params. -$result = $container->call([ReportController::class, 'generate'], [ - 'format' => 'pdf' -]); +// Automatically resolves and injects Database +$repo = $container->get(UserRepository::class); ``` -Supported callback formats: -- `'ClassName::method'` (Static calls, or auto-resolves instance for non-static) -- `[$object, 'method']` -- `[ClassName::class, 'method']` -- Closures / Anonymous functions +### Advanced Resolution Strategies -### Circular Dependency Detection +When resolving constructor or method arguments, the container follows a strict **Precedence of Resolution**: -The container tracks currently resolving classes. If Class A depends on Class B, and Class B depends on Class A, the container will throw an `Exception` to prevent an infinite loop / stack overflow. +1. **Named Parameters**: Explicit keys in the `$params` array matching the argument name. +2. **Type Hint (Auto-Substitution)**: If a provided parameter in `$params` is an object matching the required type hint, it is used immediately. +3. **Type Hint (Recursive)**: Resolves the type-hinted class/interface from the container. +4. **Variadic Parameters**: Collects all remaining arguments from the `$params` array. +5. **Positional Parameters**: Uses unkeyed arguments from `$params`. These are **type-guarded**; they are only used if they match the expected type hint. +6. **Default Values & Nullable Types**: Fallback to `$param = 'default'` or `null` (if allowed). +## Advanced Method Invocation (`call`) + +Execute any callable while automatically injecting its dependencies. + +```php +// 1. Closures +$container->call(function(MailService $mailer) { ... }); + +// 2. Class methods (auto-resolves instance) +$container->call([ReportController::class, 'generate'], ['format' => 'pdf']); + +// 3. Static methods +$container->call('Utility::process'); + +// 4. Invokable objects +$container->call(new ActionHandler()); ``` -Exception: Circular dependency detected while resolving: WpMVC\ClassA -``` -## Requirements +## Lifecycle & State + +Reset or modify the container state using the following methods: + +- `$container->forget_instances()`: Clears all cached singleton instances. +- `$container->flush()`: Clears all bindings, aliases, tags, and instances. +- **Fluent Chaining**: All registration methods support method chaining. + ```php + $container->singleton(S1::class)->alias(S1::class, 's1')->tag('s1', 'group'); + ``` + +## Exceptions + +The container throws specific exceptions, all of which are **PSR-11 compliant**: -- PHP 7.4 or higher +- `WpMVC\Container\Exception\NotFoundException`: Implements `Psr\Container\NotFoundExceptionInterface`. +- `WpMVC\Container\Exception\CircularDependencyException`: Thrown when a resolution loop is detected. +- `WpMVC\Container\Exception\ContainerException`: Implements `Psr\Container\ContainerExceptionInterface`. diff --git a/src/CallbackInvoker.php b/src/CallbackInvoker.php index f8bfe84..b21a4c3 100644 --- a/src/CallbackInvoker.php +++ b/src/CallbackInvoker.php @@ -53,13 +53,16 @@ public function __construct( ContainerInterface $container, ResolutionEngine $en } /** - * Call a callback with dependency injection. + * Invoke a callback with dependency injection. + * + * Resolves the callback format, matches its parameters with the container/params, + * and executes the call. * - * @param callable|array|string $callback - * @param array $parameters - * @return mixed - * @throws ReflectionException If reflection fails. - * @throws ContainerException If the callback is invalid. + * @param callable|array|string $callback The callback to invoke (Function, Closure, [Class, Method], etc.). + * @param array $parameters Optional manual parameters to pass to the callback. + * @return mixed The result of the callback execution. + * @throws ReflectionException If the callback cannot be introspected. + * @throws ContainerException If the callback is invalid or unresolvable. */ public function call( $callback, array $parameters = [] ) { [$resolved_callback, $metadata] = $this->resolve_callback( $callback ); @@ -103,25 +106,30 @@ protected function resolve_callback( $callback ) { } /** - * Resolve an array-based callback. + * Resolve an array-based callback [Class/ID/Object, Method]. + * + * If a class name or service ID is provided, it tries to resolve an instance + * from the container before reflecting on the method. * - * @param array $callback - * @return array [callable, \ReflectionMethod] + * @param array $callback The [target, method] array. + * @return array [callable, \ReflectionMethod] * @throws ReflectionException If reflection fails. - * @throws ContainerException If the class or service ID is not found. + * @throws ContainerException If the class, service ID, or method is invalid. */ protected function resolve_array_callback( array $callback ) { [$class_or_id, $method] = $callback; - // Determine the class name for reflection + // 1. Determine the class name and instance for reflection. if ( is_object( $class_or_id ) ) { $class = get_class( $class_or_id ); $instance = $class_or_id; } else { + // Resolve the terminal ID (in case it's an alias). $class = $this->container->resolved_id( $class_or_id ); $instance = null; - if ( ! class_exists( $class ) ) { + if ( ! class_exists( $class ) && ! interface_exists( $class ) ) { + // Check if the identifier is a bound service that can be instantiated. if ( $this->container->has( $class_or_id ) ) { $instance = $this->container->get( $class_or_id ); $class = get_class( $instance ); @@ -133,7 +141,7 @@ protected function resolve_array_callback( array $callback ) { $ref = $this->get_method_reflection( $class, $method ); - // If not static and we don't have an instance, resolve one from the container + // 2. If the method is non-static and we don't have an instance yet, resolve one. if ( ! $ref->isStatic() && ! $instance ) { $instance = $this->container->get( $class ); } @@ -164,6 +172,10 @@ protected function get_method_reflection( $class, string $method ): ReflectionMe throw new ContainerException( "Method {$class_name}::{$method} is not public." ); } + if ( $ref->isAbstract() ) { + throw new ContainerException( "Cannot call abstract method {$class_name}::{$method}." ); + } + $this->engine->cache_method( $cache_key, $ref ); return $ref; diff --git a/src/Container.php b/src/Container.php index 9e81243..58b0983 100644 --- a/src/Container.php +++ b/src/Container.php @@ -20,16 +20,21 @@ /** * Class Container * - * Enterprise-Ready Dependency Injection Container for WordPress. + * An Enterprise-Ready Dependency Injection Container for WordPress. + * + * Provides a robust implementation of the PSR-11 ContainerInterface, + * featuring reflection-based autowiring, singleton management, + * alias resolution, and contextual bindings. * * @package WpMVC\Container * - * @method $this bind(string $abstract, mixed|null $concrete = null) - * @method $this singleton(string $abstract, mixed|null $concrete = null) - * @method $this alias(string $abstract, string $alias) - * @method mixed get(string $id, array $params = []) - * @method mixed make(string $abstract, array $parameters = []) - * @method mixed call(callable|array|string $callback, array $parameters = []) + * @method $this bind(string $abstract, mixed|null $concrete = null) Register a transient binding. + * @method $this singleton(string $abstract, mixed|null $concrete = null) Register a shared binding. + * @method $this alias(string $abstract, string $alias) Alias a type to another name. + * @method mixed get(string $id, array $params = []) Resolve a service instance from the container. + * @method mixed make(string $abstract, array $parameters = []) Create a fresh instance of a class (Factory). + * @method mixed call(callable|array|string $callback, array $parameters = []) Invoke a callable with dependency injection. + * @method ContextualBindingBuilder when(string $concrete) Define a contextual binding for a specific class. */ class Container implements ContainerInterface { @@ -126,10 +131,10 @@ public function tag( $abstracts, $tags ): self { } /** - * Resolve all bindings for a given tag. + * Resolve all services associated with a given tag. * - * @param string $tag - * @return iterable + * @param string $tag The tag identifier. + * @return iterable A collection of resolved service instances. */ public function tagged( string $tag ): iterable { return array_map( @@ -139,6 +144,43 @@ function ( $abstract ) { ); } + /** + * Define a contextual binding for a specific class. + * + * Initiates the fluent API for context-sensitive dependency injection. + * + * @param string $concrete The class name that requires the contextual binding. + * @return ContextualBindingBuilder + */ + public function when( string $concrete ): ContextualBindingBuilder { + return new ContextualBindingBuilder( $this, $concrete ); + } + + /** + * Add a contextual binding for a given class. + * + * @internal + * @param string $concrete + * @param string $abstract + * @param mixed $implementation + * @return void + */ + public function add_contextual_binding( string $concrete, string $abstract, $implementation ): void { + $this->registry->add_contextual_binding( $concrete, $abstract, $implementation ); + } + + /** + * Get the contextual binding for a given class. + * + * @internal + * @param string $concrete + * @param string $abstract + * @return mixed|null + */ + public function get_contextual_binding( string $concrete, string $abstract ) { + return $this->registry->get_contextual_binding( $concrete, $abstract ); + } + /** * Get a service from the container. * @@ -152,66 +194,47 @@ function ( $abstract ) { * @throws ContainerException If instantiation fails. */ public function get( string $id, array $params = [] ) { - // 1. If it's already in instance cache, return it - if ( isset( $this->instances[$id] ) ) { - if ( ! empty( $params ) ) { - throw new ContainerException( "Cannot pass parameters to an already instantiated shared service: {$id}" ); - } - return $this->instances[$id]; - } + // 1. Resolve terminal ID immediately to ensure alias consistency. + $resolved_id = $this->registry->resolve_id( $id ); - // 2. Circular dependency detection - if ( isset( $this->resolving[$id] ) ) { - throw new CircularDependencyException( "Circular dependency detected while resolving: {$id}" ); + // 2. Direct cache hit (check if the service is already instantiated). + if ( isset( $this->instances[$resolved_id] ) ) { + return $this->instances[$resolved_id]; } - $this->resolving[$id] = true; + $concrete = $this->registry->get_concrete_internal( $resolved_id ); + $is_shared = $this->registry->is_shared_internal( $resolved_id ); - try { - // 3. Determine terminal ID and concrete - $resolved_id = $this->registry->resolve_id( $id ); - $concrete = $this->registry->get_concrete( $id ); - - // If the key is not in registry, try to autowire it as a class - $is_shared = $this->registry->has( $id ) ? $this->registry->is_shared( $id ) : true; - - // 4. Check if the resolved ID or concrete is already instantiated - $cached_key = null; - if ( isset( $this->instances[$resolved_id] ) ) { - $cached_key = $resolved_id; - } elseif ( is_string( $concrete ) && isset( $this->instances[$concrete] ) ) { - $cached_key = $concrete; - } + // 3. Cross-resolution cache hit (e.g., interface resolved to already cached concrete singleton). + if ( $is_shared && is_string( $concrete ) && isset( $this->instances[$concrete] ) ) { + return $this->instances[$resolved_id] = $this->instances[$concrete]; + } - if ( $is_shared && $cached_key !== null ) { - if ( ! empty( $params ) ) { - throw new ContainerException( "Cannot pass parameters to an already instantiated shared service: {$cached_key}" ); - } - $this->instances[$id] = $this->instances[$cached_key]; - return $this->instances[$id]; - } + // 4. Circular dependency detection using the terminal resolved ID. + if ( isset( $this->resolving[$resolved_id] ) ) { + throw new CircularDependencyException( "Circular dependency detected while resolving: {$id} (resolved to {$resolved_id})" ); + } + + $this->resolving[$resolved_id] = true; - // 5. Build the instance + try { + // 5. Build the instance via the resolution engine. $instance = $this->build( $concrete, $params, $id ); - // 6. Cache if shared + // 6. Cache the instance if the binding is shared (singleton). if ( $is_shared ) { - $this->instances[$id] = $instance; - - // Map the instance to the concrete class name too - if ( is_string( $concrete ) && $concrete !== $id ) { - $this->instances[$concrete] = $instance; - } + $this->instances[$resolved_id] = $instance; - // Map it to the terminal resolved ID too - if ( $resolved_id !== $id && $resolved_id !== $concrete ) { - $this->instances[$resolved_id] = $instance; + // If it was an interface resolution, alias the interface to the instance too. + if ( $id !== $resolved_id ) { + $this->instances[$id] = $instance; } } return $instance; } finally { - unset( $this->resolving[$id] ); + // Unset resolution flag to avoid false positives in subsequent calls. + unset( $this->resolving[$resolved_id] ); } } diff --git a/src/ContextualBindingBuilder.php b/src/ContextualBindingBuilder.php new file mode 100644 index 0000000..030ffac --- /dev/null +++ b/src/ContextualBindingBuilder.php @@ -0,0 +1,81 @@ +container = $container; + $this->concrete = $concrete; + } + + /** + * Define the abstract dependency the class needs. + * + * This is usually an interface or a class name that is injected + * into the concrete class's constructor. + * + * @param string $abstract The abstract identifier. + * @return $this + */ + public function needs( string $abstract ): self { + $this->needs = $abstract; + return $this; + } + + /** + * Define the implementation to be given when the dependency is requested. + * + * Binds the specific implementation (class name or closure) to the + * abstract dependency for the current concrete context. + * + * @param mixed $implementation The implementation to provide (Class name or Closure). + * @return void + */ + public function give( $implementation ): void { + $this->container->add_contextual_binding( $this->concrete, $this->needs, $implementation ); + } +} diff --git a/src/Registry.php b/src/Registry.php index 7a51922..9330b80 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -44,10 +44,19 @@ class Registry protected $tags = []; /** - * Register a transient binding. + * The contextual bindings. * - * @param string $abstract - * @param mixed|null $concrete + * @var array + */ + protected $contextual = []; + + /** + * Register a transient (non-shared) binding. + * + * Transient services are re-instantiated every time they are resolved. + * + * @param string $abstract The abstract identifier (interface or class name). + * @param mixed|null $concrete The concrete implementation (closure or class name). * @return void */ public function bind( string $abstract, $concrete = null ): void { @@ -99,7 +108,9 @@ public function tag( $abstracts, $tags ): void { } foreach ( $abstracts as $abstract ) { - $this->tags[$tag][] = $abstract; + if ( ! in_array( $abstract, $this->tags[$tag], true ) ) { + $this->tags[$tag][] = $abstract; + } } } } @@ -116,9 +127,12 @@ public function get_tag( string $tag ): array { /** * Resolve the terminal identifier by following all aliases. + * + * This method recursively follows aliases until it finds the root identifier. + * It includes basic circular alias detection. * - * @param string $id - * @return string + * @param string $id The identifier or alias to resolve. + * @return string The terminal (root) identifier. * @throws ContainerException If a circular alias resolution is detected. */ public function resolve_id( string $id ): string { @@ -139,9 +153,12 @@ public function resolve_id( string $id ): string { /** * Get the concrete implementation mapped to an abstract identifier. * - * @param string $abstract - * @param array $history - * @return mixed + * Performs a full resolution including alias traversal and recursion + * for chained bindings. + * + * @param string $abstract The abstract identifier to resolve. + * @param array $history Resolution history (used for circular detection). + * @return mixed The concrete implementation (target class or closure). * @throws ContainerException If a circular resolution path is detected. */ public function get_concrete( string $abstract, array $history = [] ) { @@ -153,6 +170,19 @@ public function get_concrete( string $abstract, array $history = [] ) { $id = $this->resolve_id( $abstract ); + return $this->get_concrete_internal( $id, $history ); + } + + /** + * Internal method to get concrete implementation without re-resolving ID. + * + * @internal This method assumes the ID has already been resolved via resolve_id(). + * + * @param string $id The already resolved terminal ID. + * @param array $history Resolution history. + * @return mixed + */ + public function get_concrete_internal( string $id, array $history = [] ) { if ( isset( $this->bindings[$id] ) ) { $concrete = $this->bindings[$id]['concrete']; @@ -175,9 +205,41 @@ public function get_concrete( string $abstract, array $history = [] ) { */ public function is_shared( string $abstract ): bool { $id = $this->resolve_id( $abstract ); + return $this->is_shared_internal( $id ); + } + + /** + * Internal method to check shared status without re-resolving ID. + * + * @internal + */ + public function is_shared_internal( string $id ): bool { return $this->bindings[$id]['shared'] ?? false; } + /** + * Add a contextual binding to the registry. + * + * @param string $concrete + * @param string $abstract + * @param mixed $implementation + * @return void + */ + public function add_contextual_binding( string $concrete, string $abstract, $implementation ): void { + $this->contextual[$concrete][$abstract] = $implementation; + } + + /** + * Get the contextual binding for a given concrete and abstract. + * + * @param string $concrete + * @param string $abstract + * @return mixed|null + */ + public function get_contextual_binding( string $concrete, string $abstract ) { + return $this->contextual[$concrete][$abstract] ?? null; + } + /** * Check if a binding or alias exists for the given ID. * @@ -194,8 +256,9 @@ public function has( string $id ): bool { * @return void */ public function flush(): void { - $this->bindings = []; - $this->aliases = []; - $this->tags = []; + $this->bindings = []; + $this->aliases = []; + $this->tags = []; + $this->contextual = []; } } diff --git a/src/ResolutionEngine.php b/src/ResolutionEngine.php index 72e7e8d..4d5cd66 100644 --- a/src/ResolutionEngine.php +++ b/src/ResolutionEngine.php @@ -40,14 +40,21 @@ class ResolutionEngine * @var array */ protected $reflection_cache = [ - 'constructors' => [], - 'methods' => [], + 'classes' => [], + 'methods' => [], ]; + /** + * Stack of classes currently being built. + * + * @var array + */ + protected $build_stack = []; + /** * ResolutionEngine constructor. * - * @param ContainerInterface $container + * @param ContainerInterface $container The container instance used for recursive resolution. */ public function __construct( ContainerInterface $container ) { $this->container = $container; @@ -63,25 +70,36 @@ public function __construct( ContainerInterface $container ) { * @throws ContainerException If the class is not instantiable. */ public function resolve( string $id, array $params = [] ) { - if ( ! isset( $this->reflection_cache['constructors'][$id] ) ) { + // 1. Fetch from reflection cache if available, otherwise introspect the class. + if ( ! isset( $this->reflection_cache['classes'][$id] ) ) { $ref = new ReflectionClass( $id ); if ( ! $ref->isInstantiable() ) { throw new ContainerException( "Class is not instantiable: {$id}" ); } - $this->reflection_cache['constructors'][$id] = $ref; + $this->reflection_cache['classes'][$id] = $ref; } - - $ref = $this->reflection_cache['constructors'][$id]; - $constructor = $ref->getConstructor(); - $args = []; - if ( $constructor ) { - $args = $this->resolve_dependencies( $constructor->getParameters(), $params ); - } + $ref = $this->reflection_cache['classes'][$id]; + + // 2. Track the class being built to allow for contextual dependency resolution. + $this->build_stack[] = $id; - return $ref->newInstanceArgs( $args ); + try { + $constructor = $ref->getConstructor(); + $args = []; + + if ( $constructor ) { + // 3. Resolve all dependencies required by the constructor. + $args = $this->resolve_dependencies( $constructor->getParameters(), $params ); + } + + return $ref->newInstanceArgs( $args ); + } finally { + // 4. Pop the build stack to maintain accurate context for subsequent resolutions. + array_pop( $this->build_stack ); + } } /** @@ -105,14 +123,24 @@ public function resolve_dependencies( array $parameters_metadata, array $paramet continue; } - // 2. Try to resolve by type hint + // 2. Try to resolve by type hint (Interface or Class). $type = $param->getType(); if ( $type instanceof ReflectionNamedType && ! $type->isBuiltin() ) { $id = $type->getName(); - // Optimization: Check if any provided parameter matches this type + // 2a. Check for contextual binding first. + // If the class currently being built has a specific rule for this dependency, use it. + $concrete = end( $this->build_stack ); + if ( $concrete && ( $contextual = $this->container->get_contextual_binding( $concrete, $id ) ) ) { + $args[] = $contextual instanceof \Closure + ? $contextual( $this->container, $parameters ) + : $this->container->get( $contextual, $parameters ); + continue; + } + + // 2b. Optimization: Check if any provided parameter object matches this type hint. foreach ( $parameters as $key => $provided_param ) { - if ( is_object( $provided_param ) && ( $provided_param instanceof $id || ltrim( get_class( $provided_param ), '\\' ) === ltrim( $id, '\\' ) ) ) { + if ( is_object( $provided_param ) && $provided_param instanceof $id ) { $args[] = $provided_param; unset( $parameters[$key] ); continue 2; @@ -124,11 +152,9 @@ public function resolve_dependencies( array $parameters_metadata, array $paramet $args[] = $this->container->get( $id, $parameters ); continue; } catch ( NotFoundException $e ) { - // Fall through to other resolution strategies + // Fall through to other resolution strategies (default values, nullables) } catch ( CircularDependencyException $e ) { throw $e; - } catch ( ContainerException $e ) { - // Fall through to other resolution strategies } } @@ -217,7 +243,7 @@ protected function is_valid_type( $value, $type ): bool { case 'mixed': return true; } - return ! is_object( $value ); + return false; } /** diff --git a/tests/Integration/CircularDependencyTest.php b/tests/Integration/CircularDependencyTest.php index 3943947..f2dcf63 100644 --- a/tests/Integration/CircularDependencyTest.php +++ b/tests/Integration/CircularDependencyTest.php @@ -28,6 +28,7 @@ class CircularDependencyTest extends TestCase */ public function test_singleton_integrity_regardless_of_resolution_order() { $container = new Container(); + $container->singleton( StubConcrete::class ); $container->singleton( StubInterface::class, StubConcrete::class ); // Resolve concrete FIRST diff --git a/tests/Integration/ContainerLifecycleTest.php b/tests/Integration/ContainerLifecycleTest.php index 3072151..dea9f42 100644 --- a/tests/Integration/ContainerLifecycleTest.php +++ b/tests/Integration/ContainerLifecycleTest.php @@ -73,18 +73,19 @@ public function test_it_overrides_parameters_in_make() { } /** - * Verifies that shared instances cannot be parameterized after instantiation. + * Verifies that shared instances can be parameterized (parameters are ignored if already instantiated). */ - public function test_it_guards_shared_instance_parameterization() { + public function test_it_allows_shared_instance_parameterization() { $this->container->singleton( ClassWithParams::class ); $instance = $this->container->get( ClassWithParams::class, ['value' => 'first-call'] ); $this->assertEquals( 'first-call', $instance->value ); - $this->expectException( ContainerException::class ); - $this->expectExceptionMessage( 'Cannot pass parameters to an already instantiated shared service' ); + // This should NO LONGER throw an exception + $instance2 = $this->container->get( ClassWithParams::class, ['value' => 'second-call'] ); - $this->container->get( ClassWithParams::class, ['value' => 'second-call'] ); + $this->assertSame( $instance, $instance2 ); + $this->assertEquals( 'first-call', $instance2->value ); // Parameters were ignored } /** diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 7d3587b..270a099 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -54,10 +54,22 @@ public function test_it_resolves_a_class_without_dependencies() { $this->assertInstanceOf( ConcreteClass::class, $instance ); } + /** + * Verifies that the container returns a fresh instance by default for unregistered classes. + */ + public function test_it_returns_fresh_instance_by_default() { + $instance1 = $this->container->get( ConcreteClass::class ); + $instance2 = $this->container->get( ConcreteClass::class ); + + $this->assertNotSame( $instance1, $instance2 ); + } + /** * Verifies that singletons return the same instance across multiple resolutions. */ public function test_it_returns_same_instance_for_singleton() { + $this->container->singleton( ConcreteClass::class ); + $instance1 = $this->container->get( ConcreteClass::class ); $instance2 = $this->container->get( ConcreteClass::class ); @@ -138,6 +150,19 @@ public function test_it_can_make_with_parameters_override() { $this->assertEquals( 42, $instance->number ); } + /** + * Verifies that passing parameters to an already instantiated shared service does not throw anymore. + */ + public function test_it_ignores_parameters_for_existing_shared_service() { + $this->container->singleton( ClassWithParams::class ); + + $instance1 = $this->container->get( ClassWithParams::class, ['value' => 'first', 'number' => 1] ); + $instance2 = $this->container->get( ClassWithParams::class, ['value' => 'ignored', 'number' => 2] ); + + $this->assertSame( $instance1, $instance2 ); + $this->assertEquals( 'first', $instance2->value ); + } + /** * Verifies method injection via the call() method. */ @@ -233,6 +258,10 @@ public function test_it_resolves_optional_parameters() { /** * Verifies that middleware-style calls work correctly (injecting stdClass and closure). */ + + /** + * Verifies that the container correctly resolves middleware-like calls (injecting stdClass and closure). + */ public function test_it_correctly_resolves_middleware_like_calls() { $req = new \stdClass(); $next = function() { return 'next'; }; @@ -246,4 +275,30 @@ function( \stdClass $r, $n ) { $this->assertSame( $req, $result[0] ); $this->assertEquals( 'next', $result[1] ); } + + /** + * Verifies that duplicate tags are ignored. + */ + public function test_it_prevents_duplicate_tags() { + $this->container->tag( ConcreteClass::class, ['plugin', 'service'] ); + $this->container->tag( ConcreteClass::class, ['plugin'] ); // Duplicate + + $tagged = $this->container->tagged( 'plugin' ); + $count = 0; + foreach ( $tagged as $item ) { + $count++; + } + + $this->assertEquals( 1, $count ); + } + + /** + * Verifies that calling an abstract method throws a ContainerException. + */ + public function test_it_throws_exception_on_abstract_method_call() { + $this->expectException( ContainerException::class ); + $this->expectExceptionMessage( 'Cannot call abstract method' ); + + $this->container->call( [\Psr\Container\ContainerInterface::class, 'get'] ); + } } diff --git a/tests/Unit/ContextualBindingTest.php b/tests/Unit/ContextualBindingTest.php new file mode 100644 index 0000000..00ea6b8 --- /dev/null +++ b/tests/Unit/ContextualBindingTest.php @@ -0,0 +1,65 @@ +dep = $dep; + } +} + +class ClientB { + public $dep; + + public function __construct( TestInterface $dep ) { + $this->dep = $dep; + } +} + +class ContextualBindingTest extends TestCase +{ + protected $container; + + protected function setUp(): void { + parent::setUp(); + $this->container = new Container(); + } + + public function test_contextual_binding_resolves_correctly() { + $this->container->when( ClientA::class ) + ->needs( TestInterface::class ) + ->give( ImplementationA::class ); + + $this->container->when( ClientB::class ) + ->needs( TestInterface::class ) + ->give( ImplementationB::class ); + + $client_a = $this->container->get( ClientA::class ); + $client_b = $this->container->get( ClientB::class ); + + $this->assertInstanceOf( ImplementationA::class, $client_a->dep ); + $this->assertInstanceOf( ImplementationB::class, $client_b->dep ); + } + + public function test_contextual_binding_with_closure() { + $this->container->when( ClientA::class ) + ->needs( TestInterface::class ) + ->give( + function() { + return new ImplementationA(); + } + ); + + $client_a = $this->container->get( ClientA::class ); + $this->assertInstanceOf( ImplementationA::class, $client_a->dep ); + } +} From 7da781754fb36d652522581b7e6449e5d4cb4642 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:30:50 +0600 Subject: [PATCH 5/5] Update README.md --- README.md | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eeb0eda..234b8fc 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,13 @@ composer require wpmvc/container - **Zero Configuration**: Automatically resolves dependencies using PHP Reflection. - **Singleton by Default**: Maintains single instances of services unless bound otherwise. +- **Contextual Bindings**: Inject different implementations of the same dependency based on the requesting context. - **Flexible Bindings**: Explicitly register transient, shared, or existing instances. - **Aliasing & Tagging**: Group and reference services using flexible identifiers. - **Advanced Autowiring**: Supports positional/variadic parameters, nullable types, and default values. - **Method Injection**: Supports DI for method calls via `call()`, including static methods and invokables. -- **Circular Dependency Detection**: Prevents infinite loops with clear exception reporting. +- **Circular Dependency Detection**: Robust detection using terminal resolved IDs. +- **Performance Optimized**: Flat-mapped resolution reduces registry traversal overhead. - **Fluent Interface**: Supports method chaining for configuration. - **PSR-11 Compatible**: Implements `Psr\Container\ContainerInterface`. @@ -52,6 +54,23 @@ Ensures only one instance exists within the container. $container->singleton(MyLogger::class, MyLogger::class); ``` +#### Contextual Bindings +Define different implementations based on which class is requesting the dependency. + +```php +$container->when(PhotoController::class) + ->needs(Filesystem::class) + ->give(S3Filesystem::class); + +$container->when(ProfileController::class) + ->needs(Filesystem::class) + ->give(LocalFilesystem::class); +``` + +> [!TIP] +> If you are using the full **WpMVC Framework**, you can use the fluent proxy directly from the `App` class: +> `App::instance()->when(MyClass::class)->needs(...)->give(...);` + #### Closure Bindings Use a closure for complex instantiation logic. The container is passed as the first argument. ```php @@ -97,10 +116,10 @@ $service = $container->get(MyService::class); ``` > [!NOTE] -> **Tri-cache Behavior**: For shared services, the container caches the instance under the requested ID, the terminal resolved ID, and the concrete class name. This ensures consistent resolution regardless of how the service is accessed. +> **Singleton Integrity**: For shared services, the container synchronizes instances across the terminal resolved ID (concrete) and all aliases. This ensures that resolving an interface or its concrete implementation always returns the exact same object. > [!IMPORTANT] -> Passing parameters to `get()` for an already-instantiated shared service will throw a `ContainerException`. Use `make()` if you need a fresh instance with custom parameters. +> Passing parameters to `get()` for an already-instantiated shared service will return the existing instance and ignore the new parameters to maintain singleton integrity. Use `make()` if you need a fresh instance with custom parameters. ### Creating New Instances (Factory) @@ -112,10 +131,10 @@ $freshInstance = $container->make(MyService::class, ['param' => 'value']); ### Checking Availability (`has`) -Check if a service is available. The `has()` method follows a "Slow Path" logic: -1. Check if already instantiated. -2. Check if manually registered in the registry. -3. Check if the terminal resolved ID exists as a class (`class_exists`). +Check if a service is available. The `has()` method follows a performance-optimized logic: +1. Fast-path check for cached instances or explicit registry entries. +2. Alias resolution to check terminal IDs. +3. Fallback to `class_exists` for autowiring candidates. ```php if ($container->has(MyService::class)) { @@ -164,10 +183,11 @@ When resolving constructor or method arguments, the container follows a strict * 1. **Named Parameters**: Explicit keys in the `$params` array matching the argument name. 2. **Type Hint (Auto-Substitution)**: If a provided parameter in `$params` is an object matching the required type hint, it is used immediately. -3. **Type Hint (Recursive)**: Resolves the type-hinted class/interface from the container. -4. **Variadic Parameters**: Collects all remaining arguments from the `$params` array. -5. **Positional Parameters**: Uses unkeyed arguments from `$params`. These are **type-guarded**; they are only used if they match the expected type hint. -6. **Default Values & Nullable Types**: Fallback to `$param = 'default'` or `null` (if allowed). +3. **Contextual Binding**: Checks if a specific rule exists for the class currently being resolved. +4. **Type Hint (Recursive)**: Resolves the type-hinted class/interface from the container. +5. **Variadic Parameters**: Collects all remaining arguments from the `$params` array. +6. **Positional Parameters**: Uses unkeyed arguments from `$params`. These are **type-guarded**; they are only used if they match the expected type hint. +7. **Default Values & Nullable Types**: Fallback to `$param = 'default'` or `null` (if allowed). ## Advanced Method Invocation (`call`)