├── src ├── Exception │ ├── InvocationException.php │ ├── NotEnoughParametersException.php │ └── NotCallableException.php ├── InvokerInterface.php ├── ParameterResolver │ ├── ParameterResolver.php │ ├── NumericArrayResolver.php │ ├── AssociativeArrayResolver.php │ ├── Container │ │ ├── ParameterNameContainerResolver.php │ │ └── TypeHintContainerResolver.php │ ├── DefaultValueResolver.php │ ├── TypeHintResolver.php │ └── ResolverChain.php ├── Reflection │ └── CallableReflection.php ├── Invoker.php └── CallableResolver.php ├── composer.json ├── LICENSE └── README.md /src/Exception/InvocationException.php: -------------------------------------------------------------------------------- 1 | =7.3", 20 | "psr/container": "^1.0|^2.0" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12", 24 | "athletic/athletic": "~0.1.8", 25 | "mnapoli/hard-mode": "~0.3.0" 26 | }, 27 | "config": { 28 | "allow-plugins": { 29 | "dealerdirect/phpcodesniffer-composer-installer": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ParameterResolver/ParameterResolver.php: -------------------------------------------------------------------------------- 1 | call($callable, ['foo', 'bar'])` will simply resolve the parameters 12 | * to `['foo', 'bar']`. 13 | * 14 | * Parameters that are not indexed by a number (i.e. parameter position) 15 | * will be ignored. 16 | */ 17 | class NumericArrayResolver implements ParameterResolver 18 | { 19 | public function getParameters( 20 | ReflectionFunctionAbstract $reflection, 21 | array $providedParameters, 22 | array $resolvedParameters 23 | ): array { 24 | // Skip parameters already resolved 25 | if (! empty($resolvedParameters)) { 26 | $providedParameters = array_diff_key($providedParameters, $resolvedParameters); 27 | } 28 | 29 | foreach ($providedParameters as $key => $value) { 30 | if (is_int($key)) { 31 | $resolvedParameters[$key] = $value; 32 | } 33 | } 34 | 35 | return $resolvedParameters; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ParameterResolver/AssociativeArrayResolver.php: -------------------------------------------------------------------------------- 1 | call($callable, ['foo' => 'bar'])` will inject the string `'bar'` 11 | * in the parameter named `$foo`. 12 | * 13 | * Parameters that are not indexed by a string are ignored. 14 | */ 15 | class AssociativeArrayResolver implements ParameterResolver 16 | { 17 | public function getParameters( 18 | ReflectionFunctionAbstract $reflection, 19 | array $providedParameters, 20 | array $resolvedParameters 21 | ): array { 22 | $parameters = $reflection->getParameters(); 23 | 24 | // Skip parameters already resolved 25 | if (! empty($resolvedParameters)) { 26 | $parameters = array_diff_key($parameters, $resolvedParameters); 27 | } 28 | 29 | foreach ($parameters as $index => $parameter) { 30 | if (array_key_exists($parameter->name, $providedParameters)) { 31 | $resolvedParameters[$index] = $providedParameters[$parameter->name]; 32 | } 33 | } 34 | 35 | return $resolvedParameters; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/NotCallableException.php: -------------------------------------------------------------------------------- 1 | container = $container; 23 | } 24 | 25 | public function getParameters( 26 | ReflectionFunctionAbstract $reflection, 27 | array $providedParameters, 28 | array $resolvedParameters 29 | ): array { 30 | $parameters = $reflection->getParameters(); 31 | 32 | // Skip parameters already resolved 33 | if (! empty($resolvedParameters)) { 34 | $parameters = array_diff_key($parameters, $resolvedParameters); 35 | } 36 | 37 | foreach ($parameters as $index => $parameter) { 38 | $name = $parameter->name; 39 | 40 | if ($name && $this->container->has($name)) { 41 | $resolvedParameters[$index] = $this->container->get($name); 42 | } 43 | } 44 | 45 | return $resolvedParameters; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ParameterResolver/DefaultValueResolver.php: -------------------------------------------------------------------------------- 1 | getParameters(); 19 | 20 | // Skip parameters already resolved 21 | if (! empty($resolvedParameters)) { 22 | $parameters = array_diff_key($parameters, $resolvedParameters); 23 | } 24 | 25 | foreach ($parameters as $index => $parameter) { 26 | \assert($parameter instanceof \ReflectionParameter); 27 | if ($parameter->isDefaultValueAvailable()) { 28 | try { 29 | $resolvedParameters[$index] = $parameter->getDefaultValue(); 30 | } catch (ReflectionException $e) { 31 | // Can't get default values from PHP internal classes and functions 32 | } 33 | } else { 34 | $parameterType = $parameter->getType(); 35 | if ($parameterType && $parameterType->allowsNull()) { 36 | $resolvedParameters[$index] = null; 37 | } 38 | } 39 | } 40 | 41 | return $resolvedParameters; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Reflection/CallableReflection.php: -------------------------------------------------------------------------------- 1 | getParameters(); 21 | 22 | // Skip parameters already resolved 23 | if (! empty($resolvedParameters)) { 24 | $parameters = array_diff_key($parameters, $resolvedParameters); 25 | } 26 | 27 | foreach ($parameters as $index => $parameter) { 28 | $parameterType = $parameter->getType(); 29 | if (! $parameterType) { 30 | // No type 31 | continue; 32 | } 33 | if (! $parameterType instanceof ReflectionNamedType) { 34 | // Union types are not supported 35 | continue; 36 | } 37 | if ($parameterType->isBuiltin()) { 38 | // Primitive types are not supported 39 | continue; 40 | } 41 | 42 | $parameterClass = $parameterType->getName(); 43 | if ($parameterClass === 'self') { 44 | $parameterClass = $parameter->getDeclaringClass()->getName(); 45 | } 46 | 47 | if (array_key_exists($parameterClass, $providedParameters)) { 48 | $resolvedParameters[$index] = $providedParameters[$parameterClass]; 49 | } 50 | } 51 | 52 | return $resolvedParameters; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ParameterResolver/ResolverChain.php: -------------------------------------------------------------------------------- 1 | resolvers = $resolvers; 20 | } 21 | 22 | public function getParameters( 23 | ReflectionFunctionAbstract $reflection, 24 | array $providedParameters, 25 | array $resolvedParameters 26 | ): array { 27 | $reflectionParameters = $reflection->getParameters(); 28 | 29 | foreach ($this->resolvers as $resolver) { 30 | $resolvedParameters = $resolver->getParameters( 31 | $reflection, 32 | $providedParameters, 33 | $resolvedParameters 34 | ); 35 | 36 | $diff = array_diff_key($reflectionParameters, $resolvedParameters); 37 | if (empty($diff)) { 38 | // Stop traversing: all parameters are resolved 39 | return $resolvedParameters; 40 | } 41 | } 42 | 43 | return $resolvedParameters; 44 | } 45 | 46 | /** 47 | * Push a parameter resolver after the ones already registered. 48 | */ 49 | public function appendResolver(ParameterResolver $resolver): void 50 | { 51 | $this->resolvers[] = $resolver; 52 | } 53 | 54 | /** 55 | * Insert a parameter resolver before the ones already registered. 56 | */ 57 | public function prependResolver(ParameterResolver $resolver): void 58 | { 59 | array_unshift($this->resolvers, $resolver); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ParameterResolver/Container/TypeHintContainerResolver.php: -------------------------------------------------------------------------------- 1 | container = $container; 24 | } 25 | 26 | public function getParameters( 27 | ReflectionFunctionAbstract $reflection, 28 | array $providedParameters, 29 | array $resolvedParameters 30 | ): array { 31 | $parameters = $reflection->getParameters(); 32 | 33 | // Skip parameters already resolved 34 | if (! empty($resolvedParameters)) { 35 | $parameters = array_diff_key($parameters, $resolvedParameters); 36 | } 37 | 38 | foreach ($parameters as $index => $parameter) { 39 | $parameterType = $parameter->getType(); 40 | if (! $parameterType) { 41 | // No type 42 | continue; 43 | } 44 | if (! $parameterType instanceof ReflectionNamedType) { 45 | // Union types are not supported 46 | continue; 47 | } 48 | if ($parameterType->isBuiltin()) { 49 | // Primitive types are not supported 50 | continue; 51 | } 52 | 53 | $parameterClass = $parameterType->getName(); 54 | if ($parameterClass === 'self') { 55 | $parameterClass = $parameter->getDeclaringClass()->getName(); 56 | } 57 | 58 | if ($this->container->has($parameterClass)) { 59 | $resolvedParameters[$index] = $this->container->get($parameterClass); 60 | } 61 | } 62 | 63 | return $resolvedParameters; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Invoker.php: -------------------------------------------------------------------------------- 1 | parameterResolver = $parameterResolver ?: $this->createParameterResolver(); 33 | $this->container = $container; 34 | 35 | if ($container) { 36 | $this->callableResolver = new CallableResolver($container); 37 | } 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function call($callable, array $parameters = []) 44 | { 45 | if ($this->callableResolver) { 46 | $callable = $this->callableResolver->resolve($callable); 47 | } 48 | 49 | if (! is_callable($callable)) { 50 | throw new NotCallableException(sprintf( 51 | '%s is not a callable', 52 | is_object($callable) ? 'Instance of ' . get_class($callable) : var_export($callable, true) 53 | )); 54 | } 55 | 56 | $callableReflection = CallableReflection::create($callable); 57 | 58 | $args = $this->parameterResolver->getParameters($callableReflection, $parameters, []); 59 | 60 | // Sort by array key because call_user_func_array ignores numeric keys 61 | ksort($args); 62 | 63 | // Check all parameters are resolved 64 | $diff = array_diff_key($callableReflection->getParameters(), $args); 65 | $parameter = reset($diff); 66 | if ($parameter && \assert($parameter instanceof ReflectionParameter) && ! $parameter->isVariadic()) { 67 | throw new NotEnoughParametersException(sprintf( 68 | 'Unable to invoke the callable because no value was given for parameter %d ($%s)', 69 | $parameter->getPosition() + 1, 70 | $parameter->name 71 | )); 72 | } 73 | 74 | return call_user_func_array($callable, $args); 75 | } 76 | 77 | /** 78 | * Create the default parameter resolver. 79 | */ 80 | private function createParameterResolver(): ParameterResolver 81 | { 82 | return new ResolverChain([ 83 | new NumericArrayResolver, 84 | new AssociativeArrayResolver, 85 | new DefaultValueResolver, 86 | ]); 87 | } 88 | 89 | /** 90 | * @return ParameterResolver By default it's a ResolverChain 91 | */ 92 | public function getParameterResolver(): ParameterResolver 93 | { 94 | return $this->parameterResolver; 95 | } 96 | 97 | public function getContainer(): ?ContainerInterface 98 | { 99 | return $this->container; 100 | } 101 | 102 | /** 103 | * @return CallableResolver|null Returns null if no container was given in the constructor. 104 | */ 105 | public function getCallableResolver(): ?CallableResolver 106 | { 107 | return $this->callableResolver; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/CallableResolver.php: -------------------------------------------------------------------------------- 1 | container = $container; 23 | } 24 | 25 | /** 26 | * Resolve the given callable into a real PHP callable. 27 | * 28 | * @param callable|string|array $callable 29 | * @return callable Real PHP callable. 30 | * @throws NotCallableException|ReflectionException 31 | */ 32 | public function resolve($callable): callable 33 | { 34 | if (is_string($callable) && strpos($callable, '::') !== false) { 35 | $callable = explode('::', $callable, 2); 36 | } 37 | 38 | $callable = $this->resolveFromContainer($callable); 39 | 40 | if (! is_callable($callable)) { 41 | throw NotCallableException::fromInvalidCallable($callable, true); 42 | } 43 | 44 | return $callable; 45 | } 46 | 47 | /** 48 | * @param callable|string|array $callable 49 | * @return callable|mixed 50 | * @throws NotCallableException|ReflectionException 51 | */ 52 | private function resolveFromContainer($callable) 53 | { 54 | // Shortcut for a very common use case 55 | if ($callable instanceof Closure) { 56 | return $callable; 57 | } 58 | 59 | // If it's already a callable there is nothing to do 60 | if (is_callable($callable)) { 61 | // TODO with PHP 8 that should not be necessary to check this anymore 62 | if (! $this->isStaticCallToNonStaticMethod($callable)) { 63 | return $callable; 64 | } 65 | } 66 | 67 | // The callable is a container entry name 68 | if (is_string($callable)) { 69 | try { 70 | return $this->container->get($callable); 71 | } catch (NotFoundExceptionInterface $e) { 72 | if ($this->container->has($callable)) { 73 | throw $e; 74 | } 75 | throw NotCallableException::fromInvalidCallable($callable, true); 76 | } 77 | } 78 | 79 | // The callable is an array whose first item is a container entry name 80 | // e.g. ['some-container-entry', 'methodToCall'] 81 | if (is_array($callable) && is_string($callable[0])) { 82 | try { 83 | // Replace the container entry name by the actual object 84 | $callable[0] = $this->container->get($callable[0]); 85 | return $callable; 86 | } catch (NotFoundExceptionInterface $e) { 87 | if ($this->container->has($callable[0])) { 88 | throw $e; 89 | } 90 | throw new NotCallableException(sprintf( 91 | 'Cannot call %s() on %s because it is not a class nor a valid container entry', 92 | $callable[1], 93 | $callable[0] 94 | )); 95 | } 96 | } 97 | 98 | // Unrecognized stuff, we let it fail later 99 | return $callable; 100 | } 101 | 102 | /** 103 | * Check if the callable represents a static call to a non-static method. 104 | * 105 | * @param mixed $callable 106 | * @throws ReflectionException 107 | */ 108 | private function isStaticCallToNonStaticMethod($callable): bool 109 | { 110 | if (is_array($callable) && is_string($callable[0])) { 111 | [$class, $method] = $callable; 112 | 113 | if (! method_exists($class, $method)) { 114 | return false; 115 | } 116 | 117 | $reflection = new ReflectionMethod($class, $method); 118 | 119 | return ! $reflection->isStatic(); 120 | } 121 | 122 | return false; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Invoker 2 | 3 | Generic and extensible callable invoker. 4 | 5 | [![CI](https://github.com/PHP-DI/Invoker/actions/workflows/ci.yml/badge.svg)](https://github.com/PHP-DI/Invoker/actions/workflows/ci.yml) 6 | [![Latest Version](https://img.shields.io/github/release/PHP-DI/invoker.svg?style=flat-square)](https://packagist.org/packages/PHP-DI/invoker) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/php-di/invoker.svg?style=flat-square)](https://packagist.org/packages/php-di/invoker) 8 | 9 | ## Why? 10 | 11 | Who doesn't need an over-engineered `call_user_func()`? 12 | 13 | ### Named parameters 14 | 15 | Does this [Silex](https://github.com/silexphp/Silex#readme) example look familiar: 16 | 17 | ```php 18 | $app->get('/project/{project}/issue/{issue}', function ($project, $issue) { 19 | // ... 20 | }); 21 | ``` 22 | 23 | Or this command defined with [Silly](https://github.com/mnapoli/silly#usage): 24 | 25 | ```php 26 | $app->command('greet [name] [--yell]', function ($name, $yell) { 27 | // ... 28 | }); 29 | ``` 30 | 31 | Same pattern in [Slim](https://www.slimframework.com): 32 | 33 | ```php 34 | $app->get('/hello/:name', function ($name) { 35 | // ... 36 | }); 37 | ``` 38 | 39 | You get the point. These frameworks invoke the controller/command/handler using something akin to named parameters: whatever the order of the parameters, they are matched by their name. 40 | 41 | **This library allows to invoke callables with named parameters in a generic and extensible way.** 42 | 43 | ### Dependency injection 44 | 45 | Anyone familiar with AngularJS is familiar with how dependency injection is performed: 46 | 47 | ```js 48 | angular.controller('MyController', ['dep1', 'dep2', function(dep1, dep2) { 49 | // ... 50 | }]); 51 | ``` 52 | 53 | In PHP we find this pattern again in some frameworks and DI containers with partial to full support. For example in Silex you can type-hint the application to get it injected, but it only works with `Silex\Application`: 54 | 55 | ```php 56 | $app->get('/hello/{name}', function (Silex\Application $app, $name) { 57 | // ... 58 | }); 59 | ``` 60 | 61 | In Silly, it only works with `OutputInterface` to inject the application output: 62 | 63 | ```php 64 | $app->command('greet [name]', function ($name, OutputInterface $output) { 65 | // ... 66 | }); 67 | ``` 68 | 69 | [PHP-DI](https://php-di.org/doc/container.html) provides a way to invoke a callable and resolve all dependencies from the container using type-hints: 70 | 71 | ```php 72 | $container->call(function (Logger $logger, EntityManager $em) { 73 | // ... 74 | }); 75 | ``` 76 | 77 | **This library provides clear extension points to let frameworks implement any kind of dependency injection support they want.** 78 | 79 | ### TL/DR 80 | 81 | In short, this library is meant to be a base building block for calling a function with named parameters and/or dependency injection. 82 | 83 | ## Installation 84 | 85 | ```sh 86 | $ composer require PHP-DI/invoker 87 | ``` 88 | 89 | ## Usage 90 | 91 | ### Default behavior 92 | 93 | By default the `Invoker` can call using named parameters: 94 | 95 | ```php 96 | $invoker = new Invoker\Invoker; 97 | 98 | $invoker->call(function () { 99 | echo 'Hello world!'; 100 | }); 101 | 102 | // Simple parameter array 103 | $invoker->call(function ($name) { 104 | echo 'Hello ' . $name; 105 | }, ['John']); 106 | 107 | // Named parameters 108 | $invoker->call(function ($name) { 109 | echo 'Hello ' . $name; 110 | }, [ 111 | 'name' => 'John' 112 | ]); 113 | 114 | // Use the default value 115 | $invoker->call(function ($name = 'world') { 116 | echo 'Hello ' . $name; 117 | }); 118 | 119 | // Invoke any PHP callable 120 | $invoker->call(['MyClass', 'myStaticMethod']); 121 | 122 | // Using Class::method syntax 123 | $invoker->call('MyClass::myStaticMethod'); 124 | ``` 125 | 126 | Dependency injection in parameters is supported but needs to be configured with your container. Read on or jump to [*Built-in support for dependency injection*](#built-in-support-for-dependency-injection) if you are impatient. 127 | 128 | Additionally, callables can also be resolved from your container. Read on or jump to [*Resolving callables from a container*](#resolving-callables-from-a-container) if you are impatient. 129 | 130 | ### Parameter resolvers 131 | 132 | Extending the behavior of the `Invoker` is easy and is done by implementing a [`ParameterResolver`](https://github.com/PHP-DI/Invoker/blob/master/src/ParameterResolver/ParameterResolver.php). 133 | 134 | This is explained in details the [Parameter resolvers documentation](doc/parameter-resolvers.md). 135 | 136 | #### Built-in support for dependency injection 137 | 138 | Rather than have you re-implement support for dependency injection with different containers every time, this package ships with 2 optional resolvers: 139 | 140 | - [`TypeHintContainerResolver`](https://github.com/PHP-DI/Invoker/blob/master/src/ParameterResolver/Container/TypeHintContainerResolver.php) 141 | 142 | This resolver will inject container entries by searching for the class name using the type-hint: 143 | 144 | ```php 145 | $invoker->call(function (Psr\Logger\LoggerInterface $logger) { 146 | // ... 147 | }); 148 | ``` 149 | 150 | In this example it will `->get('Psr\Logger\LoggerInterface')` from the container and inject it. 151 | 152 | This resolver is only useful if you store objects in your container using the class (or interface) name. Silex or Symfony for example store services under a custom name (e.g. `twig`, `db`, etc.) instead of the class name: in that case use the resolver shown below. 153 | 154 | - [`ParameterNameContainerResolver`](https://github.com/PHP-DI/Invoker/blob/master/src/ParameterResolver/Container/ParameterNameContainerResolver.php) 155 | 156 | This resolver will inject container entries by searching for the name of the parameter: 157 | 158 | ```php 159 | $invoker->call(function ($twig) { 160 | // ... 161 | }); 162 | ``` 163 | 164 | In this example it will `->get('twig')` from the container and inject it. 165 | 166 | These resolvers can work with any dependency injection container compliant with [PSR-11](http://www.php-fig.org/psr/psr-11/). 167 | 168 | Setting up those resolvers is simple: 169 | 170 | ```php 171 | // $container must be an instance of Psr\Container\ContainerInterface 172 | $container = ... 173 | 174 | $containerResolver = new TypeHintContainerResolver($container); 175 | // or 176 | $containerResolver = new ParameterNameContainerResolver($container); 177 | 178 | $invoker = new Invoker\Invoker; 179 | // Register it before all the other parameter resolvers 180 | $invoker->getParameterResolver()->prependResolver($containerResolver); 181 | ``` 182 | 183 | You can also register both resolvers at the same time if you wish by prepending both. Implementing support for more tricky things is easy and up to you! 184 | 185 | ### Resolving callables from a container 186 | 187 | The `Invoker` can be wired to your DI container to resolve the callables. 188 | 189 | For example with an invokable class: 190 | 191 | ```php 192 | class MyHandler 193 | { 194 | public function __invoke() 195 | { 196 | // ... 197 | } 198 | } 199 | 200 | // By default this doesn't work: an instance of the class should be provided 201 | $invoker->call('MyHandler'); 202 | 203 | // If we set up the container to use 204 | $invoker = new Invoker\Invoker(null, $container); 205 | // Now 'MyHandler' is resolved using the container! 206 | $invoker->call('MyHandler'); 207 | ``` 208 | 209 | The same works for a class method: 210 | 211 | ```php 212 | class WelcomeController 213 | { 214 | public function home() 215 | { 216 | // ... 217 | } 218 | } 219 | 220 | // By default this doesn't work: home() is not a static method 221 | $invoker->call(['WelcomeController', 'home']); 222 | 223 | // If we set up the container to use 224 | $invoker = new Invoker\Invoker(null, $container); 225 | // Now 'WelcomeController' is resolved using the container! 226 | $invoker->call(['WelcomeController', 'home']); 227 | // Alternatively we can use the Class::method syntax 228 | $invoker->call('WelcomeController::home'); 229 | ``` 230 | 231 | That feature can be used as the base building block for a framework's dispatcher. 232 | 233 | Again, any [PSR-11](https://www.php-fig.org/psr/psr-11/) compliant container can be provided. 234 | 235 | --------------------------------------------------------------------------------