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']
+ ];
+ }
+}