From e3dab4c7f0d4d7f7caeaa4536ecf1e607b1bcf58 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 4 Nov 2026 09:46:25 -0300 Subject: [PATCH] feat: Add ApplicationEnvironment, resolved from APP_ENV through recognized aliases. --- README.md | 65 +++- src/ApplicationEnvironment.php | 123 +++++++ .../ApplicationEnvironmentUnrecognized.php | 25 ++ tests/ApplicationEnvironmentTest.php | 303 ++++++++++++++++++ 4 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 src/ApplicationEnvironment.php create mode 100644 src/Exceptions/ApplicationEnvironmentUnrecognized.php create mode 100644 tests/ApplicationEnvironmentTest.php diff --git a/README.md b/README.md index ce67f4f..ba8470f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Convert to float](#convert-to-float) - [Convert to boolean](#convert-to-boolean) + [Check if the environment variable has a value](#check-if-the-environment-variable-has-a-value) + + [Application environment](#application-environment) + [Exceptions](#exceptions) + [Resolution order](#resolution-order) * [FAQ](#faq) @@ -30,6 +31,9 @@ coercions through the system. Names in the `HTTP_` namespace are read only from sources an HTTP request cannot reach, so a request header is never mistaken for configuration. See [Resolution order](#resolution-order). +`ApplicationEnvironment` resolves `APP_ENV` into a fixed set of environments, each recognizing several aliases, so code +asks `isLocal()` instead of comparing raw strings. See [Application environment](#application-environment). +
## Installation @@ -151,20 +155,58 @@ use TinyBlocks\EnvironmentVariable\EnvironmentVariable; EnvironmentVariable::from(name: 'MY_VAR')->hasValue(); ``` +### Application environment + +`ApplicationEnvironment` is the environment the application runs in. Use `current` to read it from `APP_ENV`. The +variable is read through `EnvironmentVariable::from`, so the [resolution order](#resolution-order) applies and a missing +`APP_ENV` raises `EnvironmentVariableMissing`. Use `fromAlias` when the raw value is already at hand. + +Each environment recognizes a set of aliases, matched regardless of casing and of surrounding whitespace, so `local`, +`LocalHost`, and `LOCALHOST` padded with spaces all resolve to `LOCAL`. A value no environment recognizes raises +`ApplicationEnvironmentUnrecognized`, whose message lists every accepted alias. + +| Case | Aliases | Predicate | +|---------------|-----------------------------------------------|-----------------| +| `LOCAL` | `local`, `localhost` | `isLocal` | +| `TEST` | `test`, `testing` | `isTest` | +| `DEVELOPMENT` | `development`, `dev`, `develop` | `isDevelopment` | +| `STAGING` | `staging`, `stage`, `homolog`, `homologation` | `isStaging` | +| `PRODUCTION` | `production`, `prod` | `isProduction` | + +The backing value of each case is its first alias, and `aliases` returns the full list of a case. + +```php +isLocal(); # true +$environment->isProduction(); # false + +ApplicationEnvironment::fromAlias(alias: 'Homolog'); # ApplicationEnvironment::STAGING +``` + ### Exceptions Every failure raises a dedicated class from `TinyBlocks\EnvironmentVariable\Exceptions`. All of them extend `InvalidArgumentException`, so a consumer can catch the broad type or the precise one. -| Exception | Raised by | Condition | -|------------------------------|-------------|--------------------------------------------------------| -| `EnvironmentVariableMissing` | `from` | No source holds the variable. | -| `EnvironmentValueNotInteger` | `toInteger` | The value does not represent an integer. | -| `EnvironmentValueNotFloat` | `toFloat` | The value does not represent a float. | -| `EnvironmentValueNotBoolean` | `toBoolean` | The value is not one of the recognized boolean tokens. | +| Exception | Raised by | Condition | +|--------------------------------------|------------------------|--------------------------------------------------| +| `EnvironmentVariableMissing` | `from`, `current` | No source holds the variable. | +| `EnvironmentValueNotInteger` | `toInteger` | The value does not represent an integer. | +| `EnvironmentValueNotFloat` | `toFloat` | The value does not represent a float. | +| `EnvironmentValueNotBoolean` | `toBoolean` | The value is not a recognized boolean token. | +| `ApplicationEnvironmentUnrecognized` | `fromAlias`, `current` | No application environment recognizes the alias. | -Messages carry the variable name, never its value, so a failed conversion on a secret does not leak it into logs or -stack traces. +Messages of the conversion exceptions carry the variable name, never its value, so a failed conversion on a secret does +not leak it into logs or stack traces. `ApplicationEnvironmentUnrecognized` carries the alias it received, because an +environment name is not a secret and the value is what needs fixing. ### Resolution order @@ -197,6 +239,13 @@ The empty string is one of the values PHP's boolean filter recognizes as false, variable declared but left blank therefore reads as `false`. Use `hasValue` first when the difference between blank and false matters. +### 03. Why does an unrecognized `APP_ENV` raise instead of falling back to a default? + +A silent fallback turns a typo into the wrong environment. Falling back to `PRODUCTION` would run a misconfigured local +setup against production behavior, and falling back to `LOCAL` would relax production. Raising +`ApplicationEnvironmentUnrecognized` at read time surfaces the misconfiguration where it is introduced, with the +accepted aliases in the message. +
## License diff --git a/src/ApplicationEnvironment.php b/src/ApplicationEnvironment.php new file mode 100644 index 0000000..ea8370b --- /dev/null +++ b/src/ApplicationEnvironment.php @@ -0,0 +1,123 @@ +An alias matches regardless of casing and of surrounding whitespace, so local, + * LocalHost, and LOCALHOST padded with spaces all resolve to + * {@see ApplicationEnvironment::LOCAL}.

+ */ +enum ApplicationEnvironment: string +{ + case LOCAL = 'local'; + case TEST = 'test'; + case DEVELOPMENT = 'development'; + case STAGING = 'staging'; + case PRODUCTION = 'production'; + + /** + * Creates an ApplicationEnvironment from the APP_ENV environment variable. + * + *

The variable is read through {@see EnvironmentVariable}, so the same resolution order applies.

+ * + * @return ApplicationEnvironment The environment matching the value of APP_ENV. + * @throws EnvironmentVariableMissing If APP_ENV is not set. + * @throws ApplicationEnvironmentUnrecognized If no environment recognizes the value of APP_ENV. + */ + public static function current(): ApplicationEnvironment + { + return ApplicationEnvironment::fromAlias(alias: EnvironmentVariable::from(name: 'APP_ENV')->toString()); + } + + /** + * Creates an ApplicationEnvironment from one of its aliases. + * + * @param string $alias The alias, matched regardless of casing and of surrounding whitespace. + * @return ApplicationEnvironment The environment that recognizes the alias. + * @throws ApplicationEnvironmentUnrecognized If no environment recognizes the alias. + */ + public static function fromAlias(string $alias): ApplicationEnvironment + { + $candidate = strtolower(trim($alias)); + + $recognized = array_find( + ApplicationEnvironment::cases(), + static fn(ApplicationEnvironment $environment): bool => in_array($candidate, $environment->aliases(), true) + ); + + return ($recognized ?? throw new ApplicationEnvironmentUnrecognized(alias: $alias)); + } + + /** + * Tells whether the application runs in the test environment. + * + * @return bool True if the environment is {@see ApplicationEnvironment::TEST}, false otherwise. + */ + public function isTest(): bool + { + return $this === self::TEST; + } + + /** + * Returns the aliases the environment recognizes. + * + * @return list The lowercase aliases, starting with the backing value of the case. + */ + public function aliases(): array + { + return match ($this) { + self::LOCAL => ['local', 'localhost'], + self::TEST => ['test', 'testing'], + self::DEVELOPMENT => ['development', 'dev', 'develop'], + self::STAGING => ['staging', 'stage', 'homolog', 'homologation'], + self::PRODUCTION => ['production', 'prod'] + }; + } + + /** + * Tells whether the application runs in the local environment. + * + * @return bool True if the environment is {@see ApplicationEnvironment::LOCAL}, false otherwise. + */ + public function isLocal(): bool + { + return $this === self::LOCAL; + } + + /** + * Tells whether the application runs in the staging environment. + * + * @return bool True if the environment is {@see ApplicationEnvironment::STAGING}, false otherwise. + */ + public function isStaging(): bool + { + return $this === self::STAGING; + } + + /** + * Tells whether the application runs in the production environment. + * + * @return bool True if the environment is {@see ApplicationEnvironment::PRODUCTION}, false otherwise. + */ + public function isProduction(): bool + { + return $this === self::PRODUCTION; + } + + /** + * Tells whether the application runs in the development environment. + * + * @return bool True if the environment is {@see ApplicationEnvironment::DEVELOPMENT}, false otherwise. + */ + public function isDevelopment(): bool + { + return $this === self::DEVELOPMENT; + } +} diff --git a/src/Exceptions/ApplicationEnvironmentUnrecognized.php b/src/Exceptions/ApplicationEnvironmentUnrecognized.php new file mode 100644 index 0000000..be8225b --- /dev/null +++ b/src/Exceptions/ApplicationEnvironmentUnrecognized.php @@ -0,0 +1,25 @@ + is not recognized. Accepted aliases are <%s>.'; + $acceptedAliases = implode(', ', array_map( + static fn(ApplicationEnvironment $environment): string => implode(', ', $environment->aliases()), + ApplicationEnvironment::cases() + )); + + parent::__construct(message: sprintf($template, $alias, $acceptedAliases)); + } +} diff --git a/tests/ApplicationEnvironmentTest.php b/tests/ApplicationEnvironmentTest.php new file mode 100644 index 0000000..f8f86c9 --- /dev/null +++ b/tests/ApplicationEnvironmentTest.php @@ -0,0 +1,303 @@ + is not recognized. Accepted aliases are ' + . '.'; + + protected function tearDown(): void + { + putenv('APP_ENV'); + unset($_ENV['APP_ENV'], $_SERVER['APP_ENV']); + } + + #[DataProvider('predicatesDataProvider')] + public function testPredicatesWhenEvaluatedThenOnlyTheOwnCaseHolds( + ApplicationEnvironment $environment, + array $expected + ): void { + /** @Given an application environment */ + + /** @When evaluating every environment predicate */ + $actual = [ + 'isTest' => $environment->isTest(), + 'isLocal' => $environment->isLocal(), + 'isStaging' => $environment->isStaging(), + 'isProduction' => $environment->isProduction(), + 'isDevelopment' => $environment->isDevelopment() + ]; + + /** @Then only the predicate of its own case holds */ + self::assertSame($expected, $actual); + } + + #[DataProvider('aliasesDataProvider')] + public function testAliasesWhenRequestedThenReturnsEveryRecognizedAlias( + ApplicationEnvironment $environment, + array $expected + ): void { + /** @Given an application environment */ + + /** @When requesting the aliases it recognizes */ + $actual = $environment->aliases(); + + /** @Then every recognized alias is returned in declaration order */ + self::assertSame($expected, $actual); + } + + #[DataProvider('recognizedAliasesDataProvider')] + public function testFromAliasWhenAliasIsRecognizedThenReturnsMatchingEnvironment( + string $alias, + ApplicationEnvironment $expected + ): void { + /** @Given a recognized alias of an application environment, in any casing and padding */ + + /** @When creating the application environment from the alias */ + $actual = ApplicationEnvironment::fromAlias(alias: $alias); + + /** @Then the matching application environment is returned */ + self::assertSame($expected, $actual); + } + + public function testCurrentWhenVariableIsMissingThenThrowsEnvironmentVariableMissing(): void + { + /** @Given the APP_ENV environment variable is not set */ + putenv('APP_ENV'); + + /** @Then a missing environment variable exception is expected */ + $this->expectException(EnvironmentVariableMissing::class); + $this->expectExceptionMessage('Environment variable is missing.'); + + /** @When resolving the current application environment */ + ApplicationEnvironment::current(); + } + + public function testCurrentWhenVariableIsInEnvSuperglobalThenReturnsMatchingEnvironment(): void + { + /** @Given the APP_ENV environment variable holds an alias in $_ENV */ + $_ENV['APP_ENV'] = 'homolog'; + + /** @When resolving the current application environment */ + $actual = ApplicationEnvironment::current(); + + /** @Then the matching application environment is returned */ + self::assertSame(ApplicationEnvironment::STAGING, $actual); + } + + public function testCurrentWhenVariableIsInProcessEnvironmentThenReturnsMatchingEnvironment(): void + { + /** @Given the APP_ENV environment variable holds an alias with mixed casing and padding */ + putenv(sprintf('%s=%s', 'APP_ENV', ' LocalHost ')); + + /** @When resolving the current application environment */ + $actual = ApplicationEnvironment::current(); + + /** @Then the matching application environment is returned */ + self::assertSame(ApplicationEnvironment::LOCAL, $actual); + } + + #[DataProvider('unrecognizedAliasesDataProvider')] + public function testFromAliasWhenAliasIsUnrecognizedThenThrowsApplicationEnvironmentUnrecognized( + string $alias + ): void { + /** @Given an alias no application environment recognizes */ + + /** @Then an unrecognized application environment exception is expected */ + $this->expectException(ApplicationEnvironmentUnrecognized::class); + + /** @When creating the application environment from the alias */ + ApplicationEnvironment::fromAlias(alias: $alias); + } + + public function testCurrentWhenVariableHoldsUnrecognizedAliasThenThrowsApplicationEnvironmentUnrecognized(): void + { + /** @Given the APP_ENV environment variable holds an alias no environment recognizes */ + putenv(sprintf('%s=%s', 'APP_ENV', 'qa')); + + /** @Then an unrecognized application environment exception listing the accepted aliases is expected */ + $this->expectException(ApplicationEnvironmentUnrecognized::class); + $this->expectExceptionMessage(self::UNRECOGNIZED_MESSAGE); + + /** @When resolving the current application environment */ + ApplicationEnvironment::current(); + } + + public static function aliasesDataProvider(): array + { + return [ + 'Local' => [ + 'environment' => ApplicationEnvironment::LOCAL, + 'expected' => ['local', 'localhost'] + ], + 'Test' => [ + 'environment' => ApplicationEnvironment::TEST, + 'expected' => ['test', 'testing'] + ], + 'Development' => [ + 'environment' => ApplicationEnvironment::DEVELOPMENT, + 'expected' => ['development', 'dev', 'develop'] + ], + 'Staging' => [ + 'environment' => ApplicationEnvironment::STAGING, + 'expected' => ['staging', 'stage', 'homolog', 'homologation'] + ], + 'Production' => [ + 'environment' => ApplicationEnvironment::PRODUCTION, + 'expected' => ['production', 'prod'] + ] + ]; + } + + public static function predicatesDataProvider(): array + { + return [ + 'Local' => [ + 'environment' => ApplicationEnvironment::LOCAL, + 'expected' => [ + 'isTest' => false, + 'isLocal' => true, + 'isStaging' => false, + 'isProduction' => false, + 'isDevelopment' => false + ] + ], + 'Test' => [ + 'environment' => ApplicationEnvironment::TEST, + 'expected' => [ + 'isTest' => true, + 'isLocal' => false, + 'isStaging' => false, + 'isProduction' => false, + 'isDevelopment' => false + ] + ], + 'Development' => [ + 'environment' => ApplicationEnvironment::DEVELOPMENT, + 'expected' => [ + 'isTest' => false, + 'isLocal' => false, + 'isStaging' => false, + 'isProduction' => false, + 'isDevelopment' => true + ] + ], + 'Staging' => [ + 'environment' => ApplicationEnvironment::STAGING, + 'expected' => [ + 'isTest' => false, + 'isLocal' => false, + 'isStaging' => true, + 'isProduction' => false, + 'isDevelopment' => false + ] + ], + 'Production' => [ + 'environment' => ApplicationEnvironment::PRODUCTION, + 'expected' => [ + 'isTest' => false, + 'isLocal' => false, + 'isStaging' => false, + 'isProduction' => true, + 'isDevelopment' => false + ] + ] + ]; + } + + public static function recognizedAliasesDataProvider(): array + { + return [ + 'Local' => [ + 'alias' => 'local', + 'expected' => ApplicationEnvironment::LOCAL + ], + 'Localhost' => [ + 'alias' => 'localhost', + 'expected' => ApplicationEnvironment::LOCAL + ], + 'Test' => [ + 'alias' => 'test', + 'expected' => ApplicationEnvironment::TEST + ], + 'Testing' => [ + 'alias' => 'testing', + 'expected' => ApplicationEnvironment::TEST + ], + 'Development' => [ + 'alias' => 'development', + 'expected' => ApplicationEnvironment::DEVELOPMENT + ], + 'Dev' => [ + 'alias' => 'dev', + 'expected' => ApplicationEnvironment::DEVELOPMENT + ], + 'Develop' => [ + 'alias' => 'develop', + 'expected' => ApplicationEnvironment::DEVELOPMENT + ], + 'Staging' => [ + 'alias' => 'staging', + 'expected' => ApplicationEnvironment::STAGING + ], + 'Stage' => [ + 'alias' => 'stage', + 'expected' => ApplicationEnvironment::STAGING + ], + 'Homolog' => [ + 'alias' => 'homolog', + 'expected' => ApplicationEnvironment::STAGING + ], + 'Homologation' => [ + 'alias' => 'homologation', + 'expected' => ApplicationEnvironment::STAGING + ], + 'Production' => [ + 'alias' => 'production', + 'expected' => ApplicationEnvironment::PRODUCTION + ], + 'Prod' => [ + 'alias' => 'prod', + 'expected' => ApplicationEnvironment::PRODUCTION + ], + 'Uppercase' => [ + 'alias' => 'LOCAL', + 'expected' => ApplicationEnvironment::LOCAL + ], + 'Mixed case' => [ + 'alias' => 'HomoLog', + 'expected' => ApplicationEnvironment::STAGING + ], + 'Leading and trailing spaces' => [ + 'alias' => ' dev ', + 'expected' => ApplicationEnvironment::DEVELOPMENT + ], + 'Tabs and line breaks' => [ + 'alias' => "\tPROD\n", + 'expected' => ApplicationEnvironment::PRODUCTION + ] + ]; + } + + public static function unrecognizedAliasesDataProvider(): array + { + return [ + 'Empty' => ['alias' => ''], + 'Blank' => ['alias' => ' '], + 'Unknown name' => ['alias' => 'qa'], + 'Partial alias' => ['alias' => 'prd'], + 'Inner whitespace' => ['alias' => 'local host'], + 'Alias with suffix' => ['alias' => 'production-eu'] + ]; + } +}