├── .github ├── FUNDING.yml └── workflows │ ├── phpstan.yaml │ └── testing.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── Makefile ├── README.md ├── benchmarks ├── .gitkeep └── EnvMapperBench.php ├── composer.json ├── default-.env ├── docker-compose.yml ├── docker └── php │ ├── 81 │ ├── Dockerfile │ └── xdebug.ini │ └── conf.d │ ├── error_reporting.ini │ └── pcov.ini ├── phpbench.json ├── phpstan.neon ├── phpunit.xml.dist ├── src ├── .gitkeep ├── EnvMapper.php ├── MissingEnvValue.php ├── PropValue.php └── TypeMismatch.php └── tests ├── .gitkeep ├── EnvMapperTest.php └── Envs ├── EnvWithDefaults.php ├── EnvWithMissingValue.php ├── EnvWithTypeMismatch.php └── SampleEnvironment.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Crell] 4 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PHPStan checks 3 | on: 4 | push: ~ 5 | pull_request: ~ 6 | 7 | jobs: 8 | phpstan: 9 | name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | php: [ '8.1', '8.2' ] 14 | composer-flags: [ '' ] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: ${{ matrix.php }} 20 | coverage: pcov 21 | tools: composer:v2 22 | - run: composer update --no-progress ${{ matrix.composer-flags }} 23 | - run: vendor/bin/phpstan 24 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PHPUnit tests 3 | on: 4 | push: ~ 5 | pull_request: ~ 6 | 7 | jobs: 8 | phpunit: 9 | name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | php: [ '8.1', '8.2' ] 14 | composer-flags: [ '' ] 15 | phpunit-flags: [ '--coverage-text' ] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | coverage: pcov 22 | tools: composer:v2 23 | - run: composer update --no-progress ${{ matrix.composer-flags }} 24 | - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | vendor 3 | .phpunit.cache 4 | build 5 | composer.lock 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | The Code Manifesto 2 | ================== 3 | 4 | We want to work in an ecosystem that empowers developers to reach their potential--one that encourages growth and effective collaboration. A space that is safe for all. 5 | 6 | A space such as this benefits everyone that participates in it. It encourages new developers to enter our field. It is through discussion and collaboration that we grow, and through growth that we improve. 7 | 8 | In the effort to create such a place, we hold to these values: 9 | 10 | 1. **Discrimination limits us.** This includes discrimination on the basis of race, gender, sexual orientation, gender identity, age, nationality, technology and any other arbitrary exclusion of a group of people. 11 | 2. **Boundaries honor us.** Your comfort levels are not everyone’s comfort levels. Remember that, and if brought to your attention, heed it. 12 | 3. **We are our biggest assets.** None of us were born masters of our trade. Each of us has been helped along the way. Return that favor, when and where you can. 13 | 4. **We are resources for the future.** As an extension of #3, share what you know. Make yourself a resource to help those that come after you. 14 | 5. **Respect defines us.** Treat others as you wish to be treated. Make your discussions, criticisms and debates from a position of respectfulness. Ask yourself, is it true? Is it necessary? Is it constructive? Anything less is unacceptable. 15 | 6. **Reactions require grace.** Angry responses are valid, but abusive language and vindictive actions are toxic. When something happens that offends you, handle it assertively, but be respectful. Escalate reasonably, and try to allow the offender an opportunity to explain themselves, and possibly correct the issue. 16 | 7. **Opinions are just that: opinions.** Each and every one of us, due to our background and upbringing, have varying opinions. That is perfectly acceptable. Remember this: if you respect your own opinions, you should respect the opinions of others. 17 | 8. **To err is human.** You might not intend it, but mistakes do happen and contribute to build experience. Tolerate honest mistakes, and don't hesitate to apologize if you make one yourself. 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### GNU LESSER GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | This version of the GNU Lesser General Public License incorporates the 12 | terms and conditions of version 3 of the GNU General Public License, 13 | supplemented by the additional permissions listed below. 14 | 15 | #### 0. Additional Definitions. 16 | 17 | As used herein, "this License" refers to version 3 of the GNU Lesser 18 | General Public License, and the "GNU GPL" refers to version 3 of the 19 | GNU General Public License. 20 | 21 | "The Library" refers to a covered work governed by this License, other 22 | than an Application or a Combined Work as defined below. 23 | 24 | An "Application" is any work that makes use of an interface provided 25 | by the Library, but which is not otherwise based on the Library. 26 | Defining a subclass of a class defined by the Library is deemed a mode 27 | of using an interface provided by the Library. 28 | 29 | A "Combined Work" is a work produced by combining or linking an 30 | Application with the Library. The particular version of the Library 31 | with which the Combined Work was made is also called the "Linked 32 | Version". 33 | 34 | The "Minimal Corresponding Source" for a Combined Work means the 35 | Corresponding Source for the Combined Work, excluding any source code 36 | for portions of the Combined Work that, considered in isolation, are 37 | based on the Application, and not on the Linked Version. 38 | 39 | The "Corresponding Application Code" for a Combined Work means the 40 | object code and/or source code for the Application, including any data 41 | and utility programs needed for reproducing the Combined Work from the 42 | Application, but excluding the System Libraries of the Combined Work. 43 | 44 | #### 1. Exception to Section 3 of the GNU GPL. 45 | 46 | You may convey a covered work under sections 3 and 4 of this License 47 | without being bound by section 3 of the GNU GPL. 48 | 49 | #### 2. Conveying Modified Versions. 50 | 51 | If you modify a copy of the Library, and, in your modifications, a 52 | facility refers to a function or data to be supplied by an Application 53 | that uses the facility (other than as an argument passed when the 54 | facility is invoked), then you may convey a copy of the modified 55 | version: 56 | 57 | - a) under this License, provided that you make a good faith effort 58 | to ensure that, in the event an Application does not supply the 59 | function or data, the facility still operates, and performs 60 | whatever part of its purpose remains meaningful, or 61 | - b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | #### 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from a 67 | header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | - a) Give prominent notice with each copy of the object code that 74 | the Library is used in it and that the Library and its use are 75 | covered by this License. 76 | - b) Accompany the object code with a copy of the GNU GPL and this 77 | license document. 78 | 79 | #### 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, taken 82 | together, effectively do not restrict modification of the portions of 83 | the Library contained in the Combined Work and reverse engineering for 84 | debugging such modifications, if you also do each of the following: 85 | 86 | - a) Give prominent notice with each copy of the Combined Work that 87 | the Library is used in it and that the Library and its use are 88 | covered by this License. 89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this 90 | license document. 91 | - c) For a Combined Work that displays copyright notices during 92 | execution, include the copyright notice for the Library among 93 | these notices, as well as a reference directing the user to the 94 | copies of the GNU GPL and this license document. 95 | - d) Do one of the following: 96 | - 0) Convey the Minimal Corresponding Source under the terms of 97 | this License, and the Corresponding Application Code in a form 98 | suitable for, and under terms that permit, the user to 99 | recombine or relink the Application with a modified version of 100 | the Linked Version to produce a modified Combined Work, in the 101 | manner specified by section 6 of the GNU GPL for conveying 102 | Corresponding Source. 103 | - 1) Use a suitable shared library mechanism for linking with 104 | the Library. A suitable mechanism is one that (a) uses at run 105 | time a copy of the Library already present on the user's 106 | computer system, and (b) will operate properly with a modified 107 | version of the Library that is interface-compatible with the 108 | Linked Version. 109 | - e) Provide Installation Information, but only if you would 110 | otherwise be required to provide such information under section 6 111 | of the GNU GPL, and only to the extent that such information is 112 | necessary to install and execute a modified version of the 113 | Combined Work produced by recombining or relinking the Application 114 | with a modified version of the Linked Version. (If you use option 115 | 4d0, the Installation Information must accompany the Minimal 116 | Corresponding Source and Corresponding Application Code. If you 117 | use option 4d1, you must provide the Installation Information in 118 | the manner specified by section 6 of the GNU GPL for conveying 119 | Corresponding Source.) 120 | 121 | #### 5. Combined Libraries. 122 | 123 | You may place library facilities that are a work based on the Library 124 | side by side in a single library together with other library 125 | facilities that are not Applications and are not covered by this 126 | License, and convey such a combined library under terms of your 127 | choice, if you do both of the following: 128 | 129 | - a) Accompany the combined library with a copy of the same work 130 | based on the Library, uncombined with any other library 131 | facilities, conveyed under the terms of this License. 132 | - b) Give prominent notice with the combined library that part of it 133 | is a work based on the Library, and explaining where to find the 134 | accompanying uncombined form of the same work. 135 | 136 | #### 6. Revised Versions of the GNU Lesser General Public License. 137 | 138 | The Free Software Foundation may publish revised and/or new versions 139 | of the GNU Lesser General Public License from time to time. Such new 140 | versions will be similar in spirit to the present version, but may 141 | differ in detail to address new problems or concerns. 142 | 143 | Each version is given a distinguishing version number. If the Library 144 | as you received it specifies that a certain numbered version of the 145 | GNU Lesser General Public License "or any later version" applies to 146 | it, you have the option of following the terms and conditions either 147 | of that published version or of any later version published by the 148 | Free Software Foundation. If the Library as you received it does not 149 | specify a version number of the GNU Lesser General Public License, you 150 | may choose any version of the GNU Lesser General Public License ever 151 | published by the Free Software Foundation. 152 | 153 | If the Library as you received it specifies that a proxy can decide 154 | whether future versions of the GNU Lesser General Public License shall 155 | apply, that proxy's public statement of acceptance of any version is 156 | permanent authorization for you to choose that version for the 157 | Library. 158 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # See https://tech.davis-hansson.com/p/make/ for how all of these customizations work. 2 | 3 | SHELL := bash 4 | .ONESHELL: 5 | .SHELLFLAGS := -eu -o pipefail -c 6 | .DELETE_ON_ERROR: 7 | MAKEFLAGS += --warn-undefined-variables 8 | MAKEFLAGS += --no-builtin-rules 9 | 10 | ifeq ($(origin .RECIPEPREFIX), undefined) 11 | $(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later) 12 | endif 13 | .RECIPEPREFIX = > 14 | 15 | compose_command = docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php81 16 | 17 | build: tmp/.docker-built 18 | 19 | tmp/.docker-built: docker-compose.yml docker/php/81/Dockerfile 20 | > mkdir -p $(@D) # Makes the tmp directory 21 | > docker-compose build 22 | > touch $@ # Touches the file that is this target. 23 | 24 | shell: build 25 | > $(compose_command) bash 26 | .PHONY: shell 27 | 28 | destroy: 29 | > docker-compose down -v 30 | > rm -rf tmp 31 | .PHONY: destroy 32 | 33 | composer: build 34 | > $(compose_command) composer install 35 | .PHONY: composer 36 | 37 | test: build 38 | > $(compose_command) vendor/bin/phpunit 39 | .PHONY: test 40 | 41 | phpstan: build 42 | > $(compose_command) vendor/bin/phpstan 43 | .PHONY: phpstan 44 | 45 | profile: build 46 | > $(compose_command) php profile.php 47 | .PHONY: profile 48 | 49 | blackfire: 50 | > $(compose_command) blackfire run php profile.php 51 | .PHONY: blackfire 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EnvMapper 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Total Downloads][ico-downloads]][link-downloads] 6 | 7 | Reading environment variables is a common part of most applications. However, it's often done in an ad-hoc and unsafe 8 | way, by calling `getenv()` or reading `$_ENV` from an arbitrary place in code. That means error handling, missing-value 9 | handling, default values, etc. are scattered about the code base. 10 | 11 | This library changes that. It allows you to map environment variables into arbitrary classed objects extremely fast, 12 | which allows using the class definition itself for default handling, type safety, etc. That class can then be registered 13 | in your dependency injection container to become automatically available to any service. 14 | 15 | ## Usage 16 | 17 | EnvMapper has almost no configuration. Everything is just the class. 18 | 19 | ```php 20 | // Define the class. 21 | class DbSettings 22 | { 23 | public function __construct( 24 | // Loads the DB_USER env var. 25 | public readonly string $dbUser, 26 | // Loads the DB_PASS env var. 27 | public readonly string $dbPass, 28 | // Loads the DB_HOST env var. 29 | public readonly string $dbHost, 30 | // Loads the DB_PORT env var. 31 | public readonly int $dbPort, 32 | // Loads the DB_NAME env var. 33 | public readonly string $dbName, 34 | ) {} 35 | } 36 | 37 | $mapper = new Crell\EnvMapper\EnvMapper(); 38 | 39 | $db = $mapper->map(DbSettings::class); 40 | ``` 41 | 42 | `$db` is now an instance of `DbSettings` with all five properties populated from the environment, if defined. That object 43 | may now be used anywhere, passed into a constructor, or whatever. Because its properties are all `readonly`, you 44 | can rest assured that no service using this object will be able to modify it. 45 | 46 | You can use any class you'd like, however. All that matters is the defined properties (defined via constructor promotion 47 | or not). The properties may be whatever visibility you like, and you may include whatever methods you'd like. 48 | 49 | ### Name mapping and default values 50 | 51 | EnvMapper will convert the name of the property into `UPPER_CASE` style, which is the style typically used 52 | for environment variables. It will then look for an environment variable by that name and assign it to that property. 53 | That means you may use `lowerCamel` or `snake_case` for object properties. Both will work fine. 54 | 55 | If no environment variable is found, but the property has a default value set in the class definition, that default 56 | value will be used. If there is no default value, it will be left uninitialized. 57 | 58 | Alternatively, you may set `requireValues: true` in the `map()` call. If `requireValues` is set, then a missing property will instead 59 | throw a `MissingEnvValue` exception. 60 | 61 | ```php 62 | class MissingStuff 63 | { 64 | public function __construct( 65 | public readonly string $notSet, 66 | ) {} 67 | } 68 | 69 | // This will throw a MissingEnvValue exception unless there is a NOT_SET env var defined. 70 | $mapper->map(MissingStuff::class, requireValues: true); 71 | ``` 72 | 73 | ### Type enforcement 74 | 75 | Environment variables are always strings, but you may know out-of-band that they are supposed to be an `int` or `float`. 76 | EnvMapper will automatically cast int-like values (like "5" or "42") to integers, and float-like values (like "3.14") to 77 | floats, so that they will safely assign to the typed properties on the object. 78 | 79 | If a property is typed `bool`, then the values "1", "true", "yes", and "on" (in any case) will evaluate to `true`. Anything 80 | else will evaluate to false. 81 | 82 | If a value cannot be assigned (for instance, if the `DB_PORT` environment variable was set to `"any"`), then a `TypeMismatch` 83 | exception will be thrown. 84 | 85 | ### dot-env compatibility 86 | 87 | EnvMapper reads values from `$_ENV` by default. If you are using a library that reads `.env` files into the environment, 88 | it should work fine with EnvMapper provided it populates `$_ENV`. EnvMapper does not use `getenv()` as it is much slower. 89 | 90 | Note that your [variables_order](http://php.net/variables-order) in PHP needs to be set to include `E`, for Environment, 91 | in order for `$_ENV` to be populated. If it is not, `$_ENV` will be empty. If you cannot configure your server to populate 92 | `$_ENV`, and you cannot switch to a non-broken server, as a fallback you can pass the return of `getenv()` to the `source` 93 | parameter, like so: 94 | 95 | ```php 96 | $mapper->map(Environment::class, source: getenv()); 97 | ``` 98 | 99 | ## Common patterns 100 | 101 | ### Registering with a DI Container 102 | 103 | The recommended way to use `EnvMapper` is to wire it into your Dependency Injection Container, preferably one that 104 | supports auto-wiring. For example, in a Laravel Service Provider you could do this: 105 | 106 | ```php 107 | namespace App\Providers; 108 | 109 | use App\Environment; 110 | use Crell\EnvMapper\EnvMapper; 111 | use Illuminate\Contracts\Foundation\Application; 112 | use Illuminate\Support\ServiceProvider; 113 | 114 | class EnvMapperServiceProvider extends ServiceProvider 115 | { 116 | // The EnvMapper has no constructor arguments, so registering it is simple. 117 | public $singletons = [ 118 | EnvMapper::class => EnvMapper::class, 119 | ]; 120 | 121 | public function register(): void 122 | { 123 | // When the Environment class is requested, it will be loaded lazily out of the env vars by the mapper. 124 | // Because it's a singleton, the object will be automatically cached. 125 | $this->app->singleton(Environment::class, fn(Application $app) => $app[EnvMapper::class]->map(Environment::class)); 126 | } 127 | } 128 | ``` 129 | 130 | In Symfony, you could implement the same configuration in `services.yaml`: 131 | 132 | ```yaml 133 | services: 134 | Crell\EnvMapper\EnvMapper: ~ 135 | 136 | App\Environment: 137 | factory: ['@Crell\EnvMapper\EnvMapper', 'map'] 138 | arguments: ['App\Environment'] 139 | ``` 140 | 141 | Now, any service may simply declare a constructor argument of type `Environment` and the container will automatically 142 | instantiate and inject the object as needed. 143 | 144 | ### Testing 145 | 146 | The key reason to use a central environment variable mapper is to make testing easier. Reading the environment directly 147 | from each service is a global dependency, which makes testing more difficult. Instead, making a dedicated environment 148 | class an injectable service (as in the example above) means any service that uses it may trivially be passed a manually 149 | created version. 150 | 151 | ```php 152 | class AService 153 | { 154 | public function __construct(private readonly AwsSettings $settings) {} 155 | 156 | // ... 157 | } 158 | 159 | class AServiceTest extends TestCase 160 | { 161 | public function testSomething(): void 162 | $awsSettings = new AwsSettings(awsKey: 'fake', awsSecret: 'fake'); 163 | 164 | $s = new Something($awsSettings); 165 | 166 | // ... 167 | } 168 | } 169 | ``` 170 | 171 | ### Multiple environment objects 172 | 173 | Any environment variables that are set but not present in the specified class will be ignored. That means it's trivially 174 | easy to load different variables into different classes. For example: 175 | 176 | ```php 177 | class DbSettings 178 | { 179 | public function __construct( 180 | public readonly string $dbUser, 181 | public readonly string $dbPass, 182 | public readonly string $dbHost, 183 | public readonly int $dbPort, 184 | public readonly string $dbName, 185 | ) {} 186 | } 187 | 188 | class AwsSettings 189 | { 190 | public function __construct( 191 | public readonly string $awsKey, 192 | public readonly string $awsSecret, 193 | ) {} 194 | } 195 | ``` 196 | 197 | ```php 198 | // Laravel version. 199 | class EnvMapperServiceProvider extends ServiceProvider 200 | { 201 | public function register(): void 202 | { 203 | $this->app->singleton(DbSettings::class, fn(Application $app) => $app[EnvMapper::class]->map(DbSettings::class)); 204 | $this->app->singleton(AwsSettings::class, fn(Application $app) => $app[EnvMapper::class]->map(AwsSettings::class)); 205 | } 206 | } 207 | ``` 208 | 209 | ```yaml 210 | # Symfony version 211 | services: 212 | Crell\EnvMapper\EnvMapper: ~ 213 | 214 | App\DbSettings: 215 | factory: ['@Crell\EnvMapper\EnvMapper', 'map'] 216 | arguments: ['App\DbSettings'] 217 | 218 | App\AwsSettings: 219 | factory: ['@Crell\EnvMapper\EnvMapper', 'map'] 220 | arguments: ['App\AwsSettings'] 221 | ``` 222 | 223 | ## Advanced usage 224 | 225 | EnvMapper is designed to be lightweight and fast. For that reason, its feature set is deliberately limited. 226 | 227 | However, there are cases you may wish to have a more complex environment setup. For instance, you may want to 228 | rename properties more freely, nest related properties inside sub-objects, or map comma-delimited environment variables 229 | into an array. EnvMapper is not designed to handle that. 230 | 231 | However, its sibling project [`Crell/Serde`](https://www.github.com/Crell/Serde) can do so easily. Serde is a general 232 | purpose serialization library, but you can easily feed it `$_ENV` as an array to deserialize from into an object. That 233 | gives you access to all of Serde's capability to rename, collect, nest, and otherwise translate data as it's being 234 | deserialized into an object. The basic workflow is the same, and registration in your service container is nearly 235 | identical. 236 | 237 | ```php 238 | $serde = new SerdeCommon(); 239 | 240 | $env = $serde->deserialize($_ENV, from: 'array', to: Environment::class); 241 | ``` 242 | 243 | Using Serde will be somewhat slower than using EnvMapper, but both are still very fast and suitable for almost any application. 244 | 245 | See the Serde documentation for all of its various options. 246 | 247 | ## Contributing 248 | 249 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 250 | 251 | ## Security 252 | 253 | If you discover any security related issues, please email larry at garfieldtech dot com instead of using the issue tracker. 254 | 255 | ## Credits 256 | 257 | - [Larry Garfield][link-author] 258 | - [All Contributors][link-contributors] 259 | 260 | ## License 261 | 262 | The Lesser GPL version 3 or later. Please see [License File](LICENSE.md) for more information. 263 | 264 | [ico-version]: https://img.shields.io/packagist/v/Crell/EnvMapper.svg?style=flat-square 265 | [ico-license]: https://img.shields.io/badge/License-LGPLv3-green.svg?style=flat-square 266 | [ico-downloads]: https://img.shields.io/packagist/dt/Crell/EnvMapper.svg?style=flat-square 267 | 268 | [link-packagist]: https://packagist.org/packages/Crell/EnvMapper 269 | [link-scrutinizer]: https://scrutinizer-ci.com/g/Crell/EnvMapper/code-structure 270 | [link-code-quality]: https://scrutinizer-ci.com/g/Crell/EnvMapper 271 | [link-downloads]: https://packagist.org/packages/Crell/EnvMapper 272 | [link-author]: https://github.com/Crell 273 | [link-contributors]: ../../contributors 274 | -------------------------------------------------------------------------------- /benchmarks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crell/EnvMapper/5ca3108c3f3b255d36450bec22407c20b876efec/benchmarks/.gitkeep -------------------------------------------------------------------------------- /benchmarks/EnvMapperBench.php: -------------------------------------------------------------------------------- 1 | */ 39 | protected array $source = [ 40 | 'PHP_VERSION' => '8.1.14', 41 | 'XDEBUG_MODE' => 'debug', 42 | 'PATH' => 'a value', 43 | 'HOSTNAME' => 'localhost', 44 | 'SHLVL' => '1', 45 | 'ZIP_CODE' => '01234', 46 | 'BOOL' => '1', 47 | ]; 48 | 49 | public function setUp(): void 50 | { 51 | $this->envMapper = new EnvMapper(); 52 | } 53 | 54 | public function tearDown(): void {} 55 | 56 | public function bench_envmapper(): void 57 | { 58 | /** @var SampleEnvironment $env */ 59 | $env = $this->envMapper->map(SampleEnvironment::class, source: $this->source); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crell/envmapper", 3 | "description": "A simple, fast mapper from environment variables to classed objects.", 4 | "keywords": [ 5 | "Environment", 6 | "Map" 7 | ], 8 | "homepage": "https://github.com/Crell/EnvMapper", 9 | "license": "LGPL-3.0-or-later", 10 | "authors": [ 11 | { 12 | "name": "Larry Garfield", 13 | "email": "larry@garfieldtech.com", 14 | "homepage": "http://www.garfieldtech.com/", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "~8.1" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "^10.0", 23 | "phpbench/phpbench": "^1.2", 24 | "phpstan/phpstan": "^1.10" 25 | }, 26 | "suggest": { 27 | "crell/serde": "A full and robust serialization library. It can do much more advanced manipulation when reading in env vars if EnvMapper is insufficient for your needs." 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Crell\\EnvMapper\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Crell\\EnvMapper\\": "tests", 37 | "Crell\\EnvMapper\\Benchmarks\\": "benchmarks" 38 | } 39 | }, 40 | "scripts": { 41 | "phpstan": "vendor/bin/phpstan", 42 | "all-checks": [ 43 | "phpunit", 44 | "@phpstan" 45 | ], 46 | "benchmarks": "vendor/bin/phpbench run benchmarks --report=aggregate" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /default-.env: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/compose/env-file/ 2 | 3 | # global configuration 4 | # For production 5 | #COMPOSE_FILE=docker-compose.yml 6 | # For local dev 7 | #COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml 8 | # For local dev with tunnel 9 | 10 | COMPOSE_FILE=docker-compose.yml 11 | # Ip of the host that docker can reach 12 | HOST_IP=172.17.0.1 13 | # Xdebug IDE key 14 | IDE_KEY=docker-xdebug 15 | # Port your IDE is listening on 16 | XDEBUG_PORT=9003 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # To use: 2 | # Run "docker-compose build" to rebuild the app container. 3 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php81 composer install" to install dependencies. 4 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER})--rm php81 vendor/bin/phpunit" to run the test script on 8.1. 5 | # Run "docker-compose down -v" to fully wipe everything and start over. 6 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php80 bash" to log into the container to run tests selectively. 7 | 8 | version: "3" 9 | services: 10 | php81: 11 | build: ./docker/php/81 12 | volumes: 13 | - ~/.composer:/.composer #uncomment this line to allow usage of local composer cache 14 | - .:/usr/src/myapp 15 | - ./docker/php/81/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 16 | - ./docker/php/conf.d/error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini 17 | - ./docker/php/conf.d/pcov.ini:/usr/local/etc/php/conf.d/pcov.ini 18 | environment: 19 | # This should be using pcov, but I'm having trouble getting that to enable. 20 | XDEBUG_MODE: "develop,debug,coverage" 21 | XDEBUG_CONFIG: "client_host=${HOST_IP} idekey=${IDE_KEY} client_port=${XDEBUG_PORT} discover_client_host=1 start_with_request=1" 22 | -------------------------------------------------------------------------------- /docker/php/81/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli 2 | WORKDIR /usr/src/myapp 3 | 4 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 5 | 6 | RUN apt-get update && apt-get install zip unzip git -y \ 7 | && pecl install xdebug \ 8 | && pecl install pcov 9 | -------------------------------------------------------------------------------- /docker/php/81/xdebug.ini: -------------------------------------------------------------------------------- 1 | zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so 2 | xdebug.output_dir=profiles 3 | -------------------------------------------------------------------------------- /docker/php/conf.d/error_reporting.ini: -------------------------------------------------------------------------------- 1 | error_reporting=E_ALL 2 | ; By default this is GPCS - we also want E so that $_ENV would be populated, and 3 | ; we could trigger Xdebug using an environment variable on the command line. 4 | variables_order = "EGPCS" 5 | -------------------------------------------------------------------------------- /docker/php/conf.d/pcov.ini: -------------------------------------------------------------------------------- 1 | pcov.enabled = 1 2 | pcov.directory = /usr/src/myapp/src 3 | -------------------------------------------------------------------------------- /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "runner.path": "benchmarks", 3 | "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", 4 | "runner.bootstrap": "vendor/autoload.php", 5 | "runner.php_disable_ini": true, 6 | "runner.file_pattern": "*Bench.php" 7 | } 8 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | paths: 4 | - src 5 | - tests 6 | excludePaths: 7 | - tests/Envs/* 8 | checkGenericClassInNonGenericObjectType: false 9 | ignoreErrors: 10 | # - 11 | # message: '#type has no value type specified in iterable type array#' 12 | # path: tests/ 13 | # - 14 | # message: '#type has no value type specified in iterable type iterable#' 15 | # path: tests/ 16 | # PHPStan is overly aggressive on readonly properties. 17 | - '#Class (.*) has an uninitialized readonly property (.*). Assign it in the constructor.#' 18 | - '#Readonly property (.*) is assigned outside of the constructor.#' 19 | # This is wrong, getName() is a working method on ReflectionType. But the stubs are wrong, or something. 20 | - 21 | message: "#^Call to an undefined method ReflectionType\\:\\:getName\\(\\)\\.$#" 22 | count: 1 23 | path: src/TypeMismatch.php 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | tests 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | src/ 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crell/EnvMapper/5ca3108c3f3b255d36450bec22407c20b876efec/src/.gitkeep -------------------------------------------------------------------------------- /src/EnvMapper.php: -------------------------------------------------------------------------------- 1 | > 11 | */ 12 | private array $constructorParameterList = []; 13 | 14 | /** 15 | * Maps environment variables to the specified class. 16 | * 17 | * @template T of object 18 | * @param class-string $class 19 | * The class to which to map values. 20 | * @param bool $requireValues 21 | * If true, any unmatched properties will result in an exception. If false, unmatched properties 22 | * will be ignored, which in most cases means they will be uninitialized. 23 | * @param array|null $source 24 | * The array to map from. If not specified, $_ENV will be used. Note that because the 25 | * primary use case is environment variables, the input array MUST have keys that are UPPER_CASE 26 | * strings. 27 | * @return T 28 | */ 29 | public function map(string $class, bool $requireValues = false, ?array $source = null): object 30 | { 31 | $source ??= $_ENV; 32 | 33 | $rClass = new \ReflectionClass($class); 34 | 35 | $rProperties = $rClass->getProperties(); 36 | 37 | $toSet = []; 38 | foreach ($rProperties as $rProp) { 39 | $propName = $rProp->getName(); 40 | $envName = $this->normalizeName($propName); 41 | if (isset($source[$envName])) { 42 | $toSet[$propName] = $this->typeNormalize($source[$envName], $rProp); 43 | } elseif (PropValue::None !== $default = $this->getDefaultValue($rProp)) { 44 | $toSet[$propName] = $default; 45 | } elseif ($requireValues) { 46 | throw MissingEnvValue::create($propName, $class); 47 | } 48 | } 49 | 50 | $populator = function (array $props) { 51 | foreach ($props as $k => $v) { 52 | try { 53 | $this->$k = $v; 54 | } catch (\TypeError $e) { 55 | throw TypeMismatch::create($this::class, $k, $v); 56 | } 57 | } 58 | }; 59 | 60 | $env = $rClass->newInstanceWithoutConstructor(); 61 | 62 | $populator->call($env, $toSet); 63 | 64 | return $env; 65 | } 66 | 67 | /** 68 | * Normalizes a scalar value to its most-restrictive type. 69 | * 70 | * Env values are always imported as strings, but if we want to 71 | * push them into well-typed numeric fields we need to cast them 72 | * appropriately. 73 | * 74 | * @param string $val 75 | * The value to normalize. 76 | * @return int|float|string|bool 77 | * The passed value, but now with the correct type. 78 | */ 79 | private function typeNormalize(string $val, \ReflectionProperty $rProp): int|float|string|bool 80 | { 81 | $rType = $rProp->getType(); 82 | if ($rType instanceof \ReflectionNamedType) { 83 | return match ($rType->getName()) { 84 | 'string' => $val, 85 | 'float' => is_numeric($val) 86 | ? (float) $val 87 | : throw TypeMismatch::create($rProp->getDeclaringClass()->getName(), $rProp->getName(), $val), 88 | 'int' => (is_numeric($val) && floor((float) $val) === (float) $val) 89 | ? (int) $val 90 | : throw TypeMismatch::create($rProp->getDeclaringClass()->getName(), $rProp->getName(), $val), 91 | 'bool' => in_array(strtolower($val), [1, '1', 'true', 'yes', 'on'], false), 92 | default => throw TypeMismatch::create($rProp->getDeclaringClass()->getName(), $rProp->getName(), $val), 93 | }; 94 | } 95 | 96 | throw new \RuntimeException('Compound types are not yet supported'); 97 | } 98 | 99 | /** 100 | * This is actually rather slow. Reflection's performance cost hurts here. 101 | * 102 | * We only care about defaults from the constructor; if a non-readonly property 103 | * has a default value, then newInstanceWithoutConstructor() will use it for us 104 | * and we don't need to do anything. 105 | * 106 | * @param \ReflectionProperty $subject 107 | * @return mixed 108 | */ 109 | protected function getDefaultValue(\ReflectionProperty $subject): mixed 110 | { 111 | $params = $this->getConstructorArgs($subject->getDeclaringClass()); 112 | 113 | $param = $params[$subject->getName()] ?? null; 114 | 115 | return $param?->isDefaultValueAvailable() 116 | ? $param->getDefaultValue() 117 | : PropValue::None; 118 | } 119 | 120 | /** 121 | * @return array 122 | */ 123 | protected function getConstructorArgs(\ReflectionClass $rClass): array 124 | { 125 | return $this->constructorParameterList[$rClass->getName()] ??= $this->makeConstructorArgs($rClass); 126 | } 127 | 128 | /** 129 | * @return array 130 | */ 131 | protected function makeConstructorArgs(\ReflectionClass $rClass): array 132 | { 133 | $props = []; 134 | foreach ($rClass->getConstructor()?->getParameters() ?? [] as $rProp) { 135 | $props[$rProp->getName()] = $rProp; 136 | } 137 | return $props; 138 | } 139 | 140 | /** 141 | * Normalizes a string to UPPER_CASE, as that's what env vars almost always use. 142 | */ 143 | protected function normalizeName(string $input): string 144 | { 145 | $words = preg_split( 146 | '/(^[^A-Z]+|[A-Z][^A-Z]+)/', 147 | $input, 148 | -1, /* no limit for replacement count */ 149 | PREG_SPLIT_NO_EMPTY /* don't return empty elements */ 150 | | PREG_SPLIT_DELIM_CAPTURE /* don't strip anything from output array */ 151 | ); 152 | 153 | if (!$words) { 154 | // I don't know how this is even possible. 155 | throw new \RuntimeException('Could not normalize name: ' . $input); 156 | } 157 | 158 | return \implode('_', array_map(strtoupper(...), $words)); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/MissingEnvValue.php: -------------------------------------------------------------------------------- 1 | propName = $propName; 16 | $new->class = $class; 17 | 18 | $new->message = sprintf('No matching environment variable found for property "%s" of class %s.', $propName, $class); 19 | 20 | return $new; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/PropValue.php: -------------------------------------------------------------------------------- 1 | class = $class; 17 | $new->propName = $propName; 18 | $new->envValue = $envValue; 19 | 20 | $valueType = get_debug_type($envValue); 21 | 22 | $propType = (new \ReflectionProperty($class, $propName))->getType()?->getName() ?? 'mixed'; 23 | 24 | $new->message = sprintf('Could not read environment variable for "%s" on %s. A %s was expected but %s provided.', $propName, $class, $propType, $valueType); 25 | 26 | return $new; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crell/EnvMapper/5ca3108c3f3b255d36450bec22407c20b876efec/tests/.gitkeep -------------------------------------------------------------------------------- /tests/EnvMapperTest.php: -------------------------------------------------------------------------------- 1 | */ 17 | protected array $source = [ 18 | 'PHP_VERSION' => '8.1.14', 19 | 'XDEBUG_MODE' => 'debug', 20 | 'PATH' => 'a value', 21 | 'HOSTNAME' => 'localhost', 22 | 'SHLVL' => '1', 23 | 'ZIP_CODE' => '01234', 24 | 'BOOL' => '1', 25 | ]; 26 | 27 | #[Test] 28 | public function mapping_different_types_with_defaults_parses_correctly(): void 29 | { 30 | $mapper = new EnvMapper(); 31 | 32 | /** @var SampleEnvironment $env */ 33 | $env = $mapper->map(SampleEnvironment::class, source: $this->source); 34 | 35 | self::assertNotNull($env->phpVersion); 36 | self::assertNotNull($env->xdebug_mode); 37 | self::assertNotNull($env->PATH); 38 | self::assertNotNull($env->hostname); 39 | self::assertNotNull($env->shlvl); 40 | self::assertEquals('default', $env->missing); 41 | self::assertEquals(false, $env->missingFalse); 42 | self::assertEquals('', $env->missingEmptyString); 43 | self::assertNull($env->missingNull); 44 | } 45 | 46 | #[Test] 47 | public function undefined_vars_stay_undefined_if_not_strict(): void 48 | { 49 | $mapper = new EnvMapper(); 50 | 51 | /** @var SampleEnvironment $env */ 52 | $env = $mapper->map(EnvWithMissingValue::class, source: $this->source); 53 | 54 | self::assertFalse((new \ReflectionClass($env))->getProperty('missing')->isInitialized($env)); 55 | } 56 | 57 | #[Test] 58 | public function undefined_vars_throws_if_strict(): void 59 | { 60 | $this->expectException(MissingEnvValue::class); 61 | $this->expectExceptionMessage('No matching environment variable found for property "missing" of class Crell\EnvMapper\Envs\EnvWithMissingValue.'); 62 | 63 | $mapper = new EnvMapper(); 64 | 65 | /** @var SampleEnvironment $env */ 66 | $env = $mapper->map(EnvWithMissingValue::class, requireValues: true, source: $this->source); 67 | } 68 | 69 | #[Test] 70 | public function type_mismatch(): void 71 | { 72 | $this->expectException(TypeMismatch::class); 73 | $this->expectExceptionMessage('Could not read environment variable for "path" on Crell\\EnvMapper\\Envs\\EnvWithTypeMismatch. A int was expected but string provided.'); 74 | 75 | $mapper = new EnvMapper(); 76 | 77 | /** @var EnvWithTypeMismatch $env */ 78 | $env = $mapper->map(EnvWithTypeMismatch::class, source: $this->source); 79 | 80 | $this->fail('Exception was not thrown.'); 81 | } 82 | 83 | #[Test] 84 | public function default_values_get_used(): void 85 | { 86 | $mapper = new EnvMapper(); 87 | 88 | $env = $mapper->map(EnvWithDefaults::class, source: $this->source); 89 | 90 | self::assertEquals('beep', $env->propDefault); 91 | self::assertEquals('boop', $env->promotedDefault); 92 | self::assertEquals('narf', $env->basic); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Envs/EnvWithDefaults.php: -------------------------------------------------------------------------------- 1 | basic = $basic; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Envs/EnvWithMissingValue.php: -------------------------------------------------------------------------------- 1 |