├── .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 |