Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- The `API` service is now an isolated `OpenFeatureAPI` instance created by the container (SDK 2.3.0 isolated instances) instead of the `OpenFeatureAPI::getInstance()` global singleton. Provider, hooks, and evaluation context are no longer shared with other kernels running in the same PHP process.
- `open-feature/sdk` requirement raised from `^2.2` to `^2.3` (the bundle relies on the public `OpenFeatureAPI` constructor introduced in SDK 2.3.0).
- `EnvVarProvider` and `RedisProvider` now return a `PARSE_ERROR` (and the default value) for raw values that do not match the requested type, instead of silently casting them (`"abc"` as integer used to resolve to `0`, `"banana"` as boolean to `false`).
- `InMemoryProvider` returns a `TYPE_MISMATCH` instead of casting for integers other than `0`/`1` requested as boolean (e.g. `2` or `-1`, previously `true`), non-string values requested as string (e.g. `42`, previously `"42"`), and floats requested as integer (e.g. `1.5`, previously `1`).
- `ResolutionDetailsTrait::toBool()` is replaced by `parseBool()`, `parseInt()`, `parseFloat()`, and `parseObject()`.

### Fixed

- `flags` and `providers`: keys are now kept as declared. Dashes were converted to underscores (a flag declared as `new-checkout` could only be evaluated as `new_checkout`), and an object flag holding a `name` key was renamed after that value. The undocumented list form `flags: [{name: ..., value: ...}]` is no longer supported.
- `RedisProvider` now logs Redis client failures at `error` level. Previously, an unavailable Redis silently resolved every flag to its default value, with nothing in the logs.

### Upgrade notes

- Run `composer update open-feature/sdk` if your lock file pins a version below 2.3.0.
- Code calling `OpenFeatureAPI::getInstance()` directly now gets an instance distinct from the bundle's `API` service (different provider, hooks, and evaluation context). Inject the `API` or `Client` service instead.
- **Check your raw flag values.** `EnvVarProvider` and `RedisProvider` no longer cast unparsable values: a boolean flag set to anything other than `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`/empty (e.g. `FEATURE_X=enabled`, previously `false`) or a numeric flag with a non-numeric value (for integers, decimal or exponent notation such as `10.0` or `1e3` too; leading zeros such as `08` are accepted) now resolves to the default value with a `PARSE_ERROR`. `InMemoryProvider` flags declared with a mismatching type (e.g. `max_items: 1.5` read as integer, `label: 42` read as string) now resolve to the default value with a `TYPE_MISMATCH`, like with typed providers such as flagd. In Twig, `{{ feature_value('max_items') }}` without a default reads the flag as a string and now renders `''`: pass a typed default (`feature_value('max_items', 10)`). These errors are not logged by the SDK: check the `open_feature` profiler panel in dev (error column), or register a hook that logs `ResolutionDetails::getError()` in `after()`.
- Custom providers using `ResolutionDetailsTrait::toBool()` must switch to `parseBool($flagKey, $raw, $defaultValue)`, which returns a `ResolutionDetails` instead of a `bool`.

## [0.3.0] - 2026-06-15

Expand Down
2 changes: 2 additions & 0 deletions docs/features/twig.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ The SDK method is dispatched based on the type of the default value:
| `float` | `getFloatValue()` |
| `array` | `getObjectValue()` |

Always pass a default of the flag's type. Without a default, the flag is read as a string: a boolean or integer flag then resolves to `''` with a `TYPE_MISMATCH` error, as typed providers such as flagd do.

> **Note:** The Twig extension is only registered when `twig/twig` is available in the project.
10 changes: 8 additions & 2 deletions docs/providers/env-var.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ FEATURE_MAX_ITEMS=10
FEATURE_CONFIG='{"color":"blue"}'
```

Boolean truthy values: `true`, `1`, `yes`, `on`.
| Requested type | Accepted raw values |
|---|---|
| boolean | `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive), empty string (`false`) |
| integer | whole numbers with an optional sign; leading zeros allowed (`08`), no decimal or exponent notation (`10.0`, `1e3`) |
| float | any numeric notation (`3.14`, `08`, `1e3`) |
| string | any value |
| object | a JSON object or array |

Object values must be JSON-encoded. Invalid JSON returns the default value with `ErrorCode::PARSE_ERROR`.
Any other raw value returns the default value with `ErrorCode::PARSE_ERROR` (e.g. `FEATURE_X=enabled` read as boolean).

If the environment variable is not set, the provider returns the default value with `ErrorCode::FLAG_NOT_FOUND`.
10 changes: 6 additions & 4 deletions docs/providers/in-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ A flag value must be resolved with the method matching its YAML type:

| YAML value | Resolved via |
|---|---|
| `true` / `false` | `getBooleanValue()` (also readable as `getStringValue()`) |
| `42` | `getIntegerValue()` or `getFloatValue()` (also readable as `getStringValue()`) |
| `true` / `false` | `getBooleanValue()` |
| `0` / `1` | `getBooleanValue()` (as `false` / `true`), `getIntegerValue()` or `getFloatValue()` |
| `42` | `getIntegerValue()` or `getFloatValue()` |
| `1.5` | `getFloatValue()` |
| `"dark"` | `getStringValue()` |
| `{color: blue}` | `getObjectValue()` |
| `{color: blue}` or a list | `getObjectValue()` |

Any other combination returns the default value with `ErrorCode::TYPE_MISMATCH`.
Any other combination returns the default value with `ErrorCode::TYPE_MISMATCH`. No value is cast to string: read a boolean or numeric flag with its own method.
12 changes: 10 additions & 2 deletions docs/providers/redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,18 @@ feature:config -> '{"color":"blue"}'

## Values

Boolean truthy values: `true`, `1`, `yes`, `on`.
| Requested type | Accepted raw values |
|---|---|
| boolean | `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive), empty string (`false`) |
| integer | whole numbers with an optional sign; leading zeros allowed (`08`), no decimal or exponent notation (`10.0`, `1e3`) |
| float | any numeric notation (`3.14`, `08`, `1e3`) |
| string | any value |
| object | a JSON object or array |

Object values must be JSON-encoded. Invalid JSON returns the default value with `ErrorCode::PARSE_ERROR`.
Any other raw value returns the default value with `ErrorCode::PARSE_ERROR` (e.g. `"enabled"` read as boolean).

## Error handling

If Redis is unavailable, the provider returns the default value with an error reason (`ErrorCode::GENERAL`) instead of throwing an exception. This makes it safe to use in production without risking a full outage if Redis goes down.

Each failure is logged at `error` level with the exception, so an outage shows up in your logs. During an outage, this means one log entry per flag evaluation: use Monolog's `fingers_crossed` or deduplication handlers if the volume is a concern.
15 changes: 4 additions & 11 deletions src/Provider/EnvVarProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use OpenFeature\implementation\provider\AbstractProvider;
use OpenFeature\interfaces\flags\EvaluationContext;
use OpenFeature\interfaces\provider\ErrorCode;
use OpenFeature\interfaces\provider\Provider;
use OpenFeature\interfaces\provider\ResolutionDetails;

Expand All @@ -32,7 +31,7 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found($this->toBool($raw));
return $this->parseBool($flagKey, $raw, $defaultValue);
}

public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
Expand All @@ -54,7 +53,7 @@ public function resolveIntegerValue(string $flagKey, int $defaultValue, ?Evaluat
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found((int) $raw);
return $this->parseInt($flagKey, $raw, $defaultValue);
}

public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
Expand All @@ -65,7 +64,7 @@ public function resolveFloatValue(string $flagKey, float $defaultValue, ?Evaluat
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found((float) $raw);
return $this->parseFloat($flagKey, $raw, $defaultValue);
}

/**
Expand All @@ -79,13 +78,7 @@ public function resolveObjectValue(string $flagKey, array $defaultValue, ?Evalua
return $this->flagNotFound($flagKey, $defaultValue);
}

$decoded = \json_decode($raw, true);

if (!\is_array($decoded)) {
return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" contains invalid JSON', $flagKey), $defaultValue);
}

return $this->found($decoded);
return $this->parseObject($flagKey, $raw, $defaultValue);
}

private function getEnvVar(string $flagKey): ?string
Expand Down
11 changes: 6 additions & 5 deletions src/Provider/InMemoryProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?Evalua

$value = $this->flags[$flagKey];

if (!\is_bool($value) && !\is_int($value)) {
// 0/1 accepted for YAML convenience; other integers are ambiguous
if (!\is_bool($value) && $value !== 0 && $value !== 1) {
return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type boolean', $flagKey), $defaultValue);
}

Expand All @@ -44,11 +45,11 @@ public function resolveStringValue(string $flagKey, string $defaultValue, ?Evalu

$value = $this->flags[$flagKey];

if (!\is_scalar($value)) {
if (!\is_string($value)) {
return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type string', $flagKey), $defaultValue);
}

return $this->found((string) $value);
return $this->found($value);
}

public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
Expand All @@ -59,11 +60,11 @@ public function resolveIntegerValue(string $flagKey, int $defaultValue, ?Evaluat

$value = $this->flags[$flagKey];

if (!\is_int($value) && !\is_float($value)) {
if (!\is_int($value)) {
return $this->error(ErrorCode::TYPE_MISMATCH(), \sprintf('Flag "%s" is not of type integer', $flagKey), $defaultValue);
}

return $this->found((int) $value);
return $this->found($value);
}

public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
Expand Down
89 changes: 24 additions & 65 deletions src/Provider/RedisProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,96 +27,55 @@ public function __construct(

public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
{
try {
$raw = $this->getRaw($flagKey);
} catch (\RuntimeException $e) {
return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found($this->toBool($raw));
return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseBool($flagKey, $raw, $defaultValue));
}

public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
{
try {
$raw = $this->getRaw($flagKey);
} catch (\RuntimeException $e) {
return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found($raw);
return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->found($raw));
}

public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
{
try {
$raw = $this->getRaw($flagKey);
} catch (\RuntimeException $e) {
return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found((int) $raw);
return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseInt($flagKey, $raw, $defaultValue));
}

public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
{
try {
$raw = $this->getRaw($flagKey);
} catch (\RuntimeException $e) {
return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

return $this->found((float) $raw);
return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseFloat($flagKey, $raw, $defaultValue));
}

/**
* @param mixed[] $defaultValue
*/
public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails
{
try {
$raw = $this->getRaw($flagKey);
} catch (\RuntimeException $e) {
return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

$decoded = \json_decode($raw, true);

if (!\is_array($decoded)) {
return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" contains invalid JSON', $flagKey), $defaultValue);
}

return $this->found($decoded);
return $this->resolveRaw($flagKey, $defaultValue, fn (string $raw) => $this->parseObject($flagKey, $raw, $defaultValue));
}

private function getRaw(string $flagKey): ?string
/**
* @param bool|float|int|mixed[]|string $defaultValue
* @param \Closure(string): ResolutionDetails $parse
*/
private function resolveRaw(string $flagKey, bool|string|int|float|array $defaultValue, \Closure $parse): ResolutionDetails
{
try {
$value = $this->client->get($this->prefix . $flagKey);
$raw = $this->client->get($this->prefix . $flagKey);
} catch (\Throwable $e) {
throw new \RuntimeException($e->getMessage(), previous: $e);
// The SDK does not log errors returned in ResolutionDetails
$this->logger?->error('OpenFeature Redis provider failed to read flag "{flag}": {message}', [
'flag' => $flagKey,
'message' => $e->getMessage(),
'exception' => $e,
]);

return $this->error(ErrorCode::GENERAL(), \sprintf('Flag "%s": %s', $flagKey, $e->getMessage()), $defaultValue);
}

if ($raw === false || $raw === null) {
return $this->flagNotFound($flagKey, $defaultValue);
}

return ($value === false || $value === null) ? null : $value;
return $parse($raw);
}
}
39 changes: 37 additions & 2 deletions src/Provider/ResolutionDetailsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,43 @@ private function error(ErrorCode $code, string $message, bool|string|int|float|a
->build();
}

private function toBool(string $value): bool
/**
* Accepts true/false, 1/0, yes/no, on/off (case-insensitive) and the empty string (false).
*/
private function parseBool(string $flagKey, string $raw, bool $defaultValue): ResolutionDetails
{
return \in_array(\strtolower($value), ['true', '1', 'yes', 'on'], true);
$value = \filter_var($raw, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE);

return $value === null ? $this->parseError($flagKey, 'boolean', $defaultValue) : $this->found($value);
}

private function parseInt(string $flagKey, string $raw, int $defaultValue): ResolutionDetails
{
// Leading zeros are accepted ("08"), as FILTER_VALIDATE_FLOAT already does
$normalized = \preg_replace('/^([+-]?)0+(?=\d)/', '$1', \trim($raw));
$value = \filter_var($normalized, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE);

return $value === null ? $this->parseError($flagKey, 'integer', $defaultValue) : $this->found($value);
}

private function parseFloat(string $flagKey, string $raw, float $defaultValue): ResolutionDetails
{
$value = \filter_var($raw, \FILTER_VALIDATE_FLOAT, \FILTER_NULL_ON_FAILURE);

return $value === null ? $this->parseError($flagKey, 'float', $defaultValue) : $this->found($value);
}

/** @param mixed[] $defaultValue */
private function parseObject(string $flagKey, string $raw, array $defaultValue): ResolutionDetails
{
$value = \json_decode($raw, true);

return \is_array($value) ? $this->found($value) : $this->parseError($flagKey, 'JSON object', $defaultValue);
}

/** @param bool|float|int|mixed[]|string $defaultValue */
private function parseError(string $flagKey, string $type, bool|string|int|float|array $defaultValue): ResolutionDetails
{
return $this->error(ErrorCode::PARSE_ERROR(), \sprintf('Flag "%s" is not a valid %s', $flagKey, $type), $defaultValue);
}
}
Loading
Loading