├── .github └── workflows │ └── php.yml ├── LICENSE ├── README.md └── src └── Props ├── BadMethodCallException.php ├── Container.php ├── FactoryUncallableException.php ├── NotFoundException.php ├── Pimple.php └── ValueUnresolvableException.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | php-versions: 20 | ["8.1", "8.2", "8.3"] 21 | 22 | name: PHP ${{ matrix.php-versions }} Test on ubantu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Setup PHP 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php-versions }} 30 | coverage: none 31 | tools: composer, wp-cli, phpunit-polyfills:1.0 32 | env: 33 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Validate composer.json and composer.lock 36 | run: composer validate --strict 37 | 38 | - name: Cache Composer packages 39 | id: composer-cache 40 | uses: actions/cache@v3 41 | with: 42 | path: vendor 43 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-php- 46 | 47 | - name: Install dependencies 48 | run: composer install --prefer-dist --no-progress 49 | 50 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 51 | # Docs: https://getcomposer.org/doc/articles/scripts.md 52 | 53 | - name: Run test suite 54 | run: composer run-script test 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 The Authors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Props [![Build Status](https://github.com/mrclay/Props/actions/workflows/php.yml/badge.svg)](https://github.com/mrclay/Props/actions) 2 | 3 | Most [Dependency Injection](http://www.mrclay.org/2014/04/06/dependency-injection-ask-for-what-you-need/) containers have fetch operations, like `$di->get('foo')` or `$di['foo']`, which don't allow your IDE to know the type of value received, nor offer you any help remembering/typing key names. 4 | 5 | With **Props**, you access values via custom property reads `$di->foo` or method calls `$di->new_foo()`. This allows you to subclass the container and provide `@property` and/or `@method` PHPDoc declarations, giving your IDE and static analysis tools valuable runtime type information. 6 | 7 | An example will help: 8 | 9 | ```php 10 | /** 11 | * @property-read Foo $foo 12 | * @method Foo new_foo() 13 | */ 14 | class MyContainer extends \Props\Container { 15 | public function __construct() { 16 | $this->foo = function (MyContainer $c) { 17 | return new Foo(); 18 | }; 19 | } 20 | } 21 | 22 | $c = new MyContainer(); 23 | 24 | $foo1 = $c->foo; // your IDE knows this is a Foo instance 25 | 26 | $foo2 = $c->new_foo(); // A fresh Foo instance 27 | 28 | $foo3 = $c->foo; // same as $foo1 29 | ``` 30 | 31 | Here's a more complex example: 32 | 33 | ```php 34 | /** 35 | * @property-read string $style 36 | * @property-read Dough $dough 37 | * @property-read Cheese $cheese 38 | * @property-read Pizza $pizza 39 | * @method Slice new_slice() 40 | */ 41 | class PizzaServices extends \Props\Container { 42 | public function __construct() { 43 | $this->style = 'deluxe'; 44 | 45 | $this->dough = function (PizzaServices $c) { 46 | return new Dough(); 47 | }; 48 | 49 | $this->setFactory('cheese', 'CheeseFactory::getCheese'); 50 | 51 | $this->pizza = function (PizzaServices $c) { 52 | $pizza = new Pizza($c->style, $c->cheese); 53 | $pizza->setDough($c->dough); 54 | return $pizza; 55 | }; 56 | 57 | $this->slice = function (PizzaServices $c) { 58 | return $c->pizza->getSlice(); 59 | }; 60 | } 61 | } 62 | 63 | $c = new PizzaServices; 64 | 65 | $c->pizza; // This first resolves and caches the cheese and dough. 66 | 67 | $c->pizza; // The same pizza instance as above (no factories called). 68 | ``` 69 | 70 | Since "slice" has a factory function set, we can call `new_slice()` to get fresh instances from it: 71 | 72 | ```php 73 | $c->new_slice(); // a new Slice instance 74 | $c->new_slice(); // a new Slice instance 75 | ``` 76 | 77 | Your IDE sees the container as a plain old class of typed properties, allowing it to offer suggestions of available properties, autocomplete their names, and autocomplete the objects returned. It gives you much more power when providing static analysis and automated refactoring. 78 | 79 | ## Compatibility 80 | 81 | `Props\Container` implements [`ContainerInterface`](https://github.com/php-fig/container/blob/master/src/ContainerInterface.php). 82 | 83 | ## Overview 84 | 85 | You can specify dependencies via direct setting: 86 | 87 | ```php 88 | $c->aaa = new AAA(); 89 | ``` 90 | 91 | You can specify factories by setting a `Closure`, or by using the `setFactory()` method. These are functionally equivalent: 92 | 93 | ```php 94 | $c->bbb = function ($c) { 95 | return BBB::factory($c); 96 | }; 97 | 98 | $c->setFactory('bbb', 'BBB::factory'); 99 | ``` 100 | 101 | Resolved dependencies are cached, returning the same instance: 102 | 103 | ```php 104 | $c->bbb === $c->bbb; // true 105 | ``` 106 | 107 | ### Using factories 108 | 109 | If you don't want a cached value, use `new_PROPERTYNAME()` to always fetch a fresh instance: 110 | 111 | ```php 112 | $c->new_bbb() === $c->new_bbb(); // false 113 | ``` 114 | 115 | Regular value sets do not store a factory, so you may want to check `hasFactory()` before you use `new_PROPERTYNAME()`: 116 | 117 | ```php 118 | // store a value 119 | $c->ccc = new CCC(); 120 | $c->hasFactory('ccc'); // false 121 | 122 | // store a factory 123 | $c->ccc = function () { 124 | return new CCC(); 125 | }; 126 | $c->hasFactory('ccc'); // true 127 | ``` 128 | 129 | You can also get access to a set factory: 130 | 131 | ```php 132 | $callable = $c->getFactory('ccc'); 133 | ``` 134 | 135 | ### Extending a factory 136 | 137 | Use `extend` to have the return value of a factory filtered before it's returned: 138 | 139 | ```php 140 | $c->foo = function ($c) { 141 | return new Foo($c->bar); 142 | }; 143 | 144 | $c->extend('foo', function ($value, Container $c) { 145 | return array($value, $c->bing); 146 | }); 147 | 148 | $c->foo; // [Foo, "bing"] 149 | 150 | $c->new_foo(); // re-call original foo factory and re-extend output (`bar` and `bing` will be re-read) 151 | ``` 152 | 153 | ## Pimple with property access 154 | 155 | If you're used to the [Pimple](http://pimple.sensiolabs.org/) API, try `Props\Pimple`, which just adds property access. With that you can add `@property` declarations and get the same typing benefits. 156 | 157 | You can see an [example](https://github.com/mrclay/Props/blob/master/scripts/example-pimple.php) that's similar to the Pimple docs. 158 | 159 | ## Requirements 160 | 161 | * PHP 8.1 162 | 163 | ### License (MIT) 164 | 165 | See [LICENSE](https://github.com/mrclay/Props/blob/master/src/LICENSE). 166 | -------------------------------------------------------------------------------- /src/Props/BadMethodCallException.php: -------------------------------------------------------------------------------- 1 | cache)) { 37 | return $this->cache[$name]; 38 | } 39 | $value = $this->build($name); 40 | $this->cache[$name] = $value; 41 | return $value; 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function get($name) 48 | { 49 | return $this->__get($name); 50 | } 51 | 52 | /** 53 | * Set a value. 54 | * 55 | * @param string $name 56 | * @param mixed $value 57 | * @throws \InvalidArgumentException 58 | */ 59 | public function __set($name, $value) 60 | { 61 | if ($value instanceof \Closure) { 62 | $this->setFactory($name, $value); 63 | return; 64 | } 65 | 66 | $this->cache[$name] = $value; 67 | unset($this->factories[$name]); 68 | } 69 | 70 | /** 71 | * Set a value to be later returned as is. You only need to use this if you wish to store 72 | * a Closure. 73 | * 74 | * @param string $name 75 | * @param mixed $value 76 | * @throws \InvalidArgumentException 77 | */ 78 | public function setValue($name, $value) 79 | { 80 | unset($this->factories[$name]); 81 | $this->cache[$name] = $value; 82 | } 83 | 84 | /** 85 | * @param string $name 86 | */ 87 | public function __unset($name) 88 | { 89 | unset($this->cache[$name]); 90 | unset($this->factories[$name]); 91 | } 92 | 93 | /** 94 | * @param string $name 95 | * @return bool 96 | */ 97 | public function __isset($name) 98 | { 99 | return array_key_exists($name, $this->factories) || array_key_exists($name, $this->cache); 100 | } 101 | 102 | /** 103 | * {@inheritdoc} 104 | */ 105 | public function has(string $name): bool 106 | { 107 | return $this->__isset($name); 108 | } 109 | 110 | /** 111 | * Fetch a freshly-resolved value. 112 | * 113 | * @param string $method method name must start with "new_" 114 | * @param array $args 115 | * @return mixed 116 | * @throws BadMethodCallException 117 | */ 118 | public function __call($method, $args) 119 | { 120 | if (0 !== strpos($method, 'new_')) { 121 | throw new BadMethodCallException("Method name must begin with 'new_'"); 122 | } 123 | 124 | return $this->build(substr($method, 4)); 125 | } 126 | 127 | /** 128 | * Can we fetch a new value via new_$name()? 129 | * 130 | * @param string $name 131 | * @return bool 132 | */ 133 | public function hasFactory($name) 134 | { 135 | return array_key_exists($name, $this->factories); 136 | } 137 | 138 | /** 139 | * Set a factory to generate a value when the container is read. 140 | * 141 | * @param string $name The name of the value 142 | * @param callable $factory Factory for the value 143 | * @throws FactoryUncallableException 144 | */ 145 | public function setFactory($name, $factory) 146 | { 147 | if (!is_callable($factory, true)) { 148 | throw new FactoryUncallableException('$factory must appear callable'); 149 | } 150 | 151 | unset($this->cache[$name]); 152 | $this->factories[$name] = $factory; 153 | } 154 | 155 | /** 156 | * Get an already-set factory callable (Closure, invokable, or callback) 157 | * 158 | * @param string $name The name of the value 159 | * @return callable 160 | * @throws NotFoundException 161 | */ 162 | public function getFactory($name) 163 | { 164 | if (!array_key_exists($name, $this->factories)) { 165 | throw new NotFoundException("No factory available for: $name"); 166 | } 167 | 168 | return $this->factories[$name]; 169 | } 170 | 171 | /** 172 | * Add a function that gets applied to the return value of an existing factory 173 | * 174 | * @note A cached value (from a previous property read) will thrown away. The next property read 175 | * (and all new_NAME() calls) will call the original factory. 176 | * 177 | * @param string $name The name of the value 178 | * @param callable $extender Function that is applied to extend the returned value 179 | * @return \Closure 180 | * @throws FactoryUncallableException|NotFoundException 181 | */ 182 | public function extend($name, $extender) 183 | { 184 | if (!is_callable($extender, true)) { 185 | throw new FactoryUncallableException('$extender must appear callable'); 186 | } 187 | 188 | if (!array_key_exists($name, $this->factories)) { 189 | throw new NotFoundException("No factory available for: $name"); 190 | } 191 | 192 | $factory = $this->factories[$name]; 193 | 194 | $newFactory = function (Container $c) use ($extender, $factory) { 195 | return call_user_func($extender, call_user_func($factory, $c), $c); 196 | }; 197 | 198 | $this->setFactory($name, $newFactory); 199 | 200 | return $newFactory; 201 | } 202 | 203 | /** 204 | * Get all keys available 205 | * 206 | * @return string[] 207 | */ 208 | public function getKeys() 209 | { 210 | $keys = array_keys($this->cache) + array_keys($this->factories); 211 | return array_unique($keys); 212 | } 213 | 214 | /** 215 | * Build a value 216 | * 217 | * @param string $name 218 | * @return mixed 219 | * @throws FactoryUncallableException|ValueUnresolvableException|NotFoundException 220 | */ 221 | private function build($name) 222 | { 223 | if (!array_key_exists($name, $this->factories)) { 224 | throw new NotFoundException("Missing value: $name"); 225 | } 226 | 227 | $factory = $this->factories[$name]; 228 | 229 | if (is_callable($factory)) { 230 | try { 231 | return call_user_func($factory, $this); 232 | } catch (\Exception $e) { 233 | throw new ValueUnresolvableException("Factory for '$name' threw an exception.", 0, $e); 234 | } 235 | } 236 | 237 | $msg = "Factory for '$name' was uncallable"; 238 | if (is_string($factory)) { 239 | $msg .= ": '$factory'"; 240 | } elseif (is_array($factory)) { 241 | if (is_string($factory[0])) { 242 | $msg .= ": '{$factory[0]}::{$factory[1]}'"; 243 | } else { 244 | $msg .= ": " . get_class($factory[0]) . "->{$factory[1]}"; 245 | } 246 | } 247 | throw new FactoryUncallableException($msg); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/Props/FactoryUncallableException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class Pimple extends \Pimple\Container 11 | { 12 | /** 13 | * Sets a parameter or an object. 14 | * 15 | * @param string $id The unique identifier for the parameter or object 16 | * @param mixed $value The value of the parameter or a closure to define an object 17 | * @throws \RuntimeException Prevent override of a frozen service 18 | */ 19 | public function __set($id, $value) 20 | { 21 | $this->offsetSet($id, $value); 22 | } 23 | 24 | /** 25 | * Gets a parameter or an object. 26 | * 27 | * @param string $id The unique identifier for the parameter or object 28 | * @return mixed The value of the parameter or an object 29 | * @throws \InvalidArgumentException if the identifier is not defined 30 | */ 31 | public function __get($id) 32 | { 33 | return $this->offsetGet($id); 34 | } 35 | 36 | /** 37 | * Checks if a parameter or an object is set. 38 | * 39 | * @param string $id The unique identifier for the parameter or object 40 | * @return Boolean 41 | */ 42 | public function __isset($id) 43 | { 44 | return $this->offsetExists($id); 45 | } 46 | 47 | /** 48 | * Unsets a parameter or an object. 49 | * 50 | * @param string $id The unique identifier for the parameter or object 51 | */ 52 | public function __unset($id) 53 | { 54 | $this->offsetUnset($id); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Props/ValueUnresolvableException.php: -------------------------------------------------------------------------------- 1 |