epignosis / flipster
An OpenFeature toolkit for PHP: inject any provider, wrap it in a circuit breaker, and fall back to configured defaults when it degrades.
Requires
- php: ^8.1
- ackintosh/ganesha: ^4.0
- open-feature/sdk: ^2.3
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.8
- open-feature/flagd-provider: ^1.1
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.5
Suggests
None
Provides
None
Conflicts
- myclabs/php-enum: <1.8.1
Replaces
None
README
An OpenFeature toolkit for PHP. Inject any provider, wrap it in a circuit breaker, and serve declared defaults when it degrades.
Flipster does not replace the OpenFeature SDK or invent a competing flag API. It closes three gaps that every team otherwise solves again:
- Wiring — the SDK's idiomatic entry point is a process-global singleton. Flipster gives you a narrow interface to inject instead.
- Blast radius — a flag provider is a network call on your hot path. Flipster puts a circuit breaker in front of it.
- Testability — the failure paths are the ones worth testing. Flipster ships doubles for them.
composer require epignosis/flipster
Thirty seconds
use Epignosis\Flipster\Flipster; $provider = ...; // Setup the actual provider $breaker = ...; // Setup the circuit breaker $flags = Flipster::for($provider) ->withDefaults([ 'new-checkout' => false, 'checkout-variant' => 'control', 'rate-limit-rpm' => 100, ]) ->withBreaker($breaker, 'flags') ->evaluator(); // Inject $flags as a FlagEvaluator. Domain code sees nothing else. if ($flags->isEnabled('new-checkout')) { // ... }
Note what the call site does not contain a default value. Defaults are declared once, in one place, so a call site cannot disagree with configuration.
A runnable version of the above — no backend required — is
examples/in-memory.php:
./dev example in-memory
What happens when the provider breaks
This is the part worth reading carefully, because it is the reason the library exists.
| Situation | Value served | reason |
|---|---|---|
| Provider answers | the live value, untouched | as the provider reported |
| Provider errors, flag declared | the declared default | ERROR |
| Circuit open, flag declared | the declared default (provider not called) | ERROR |
| Degraded, flag not declared | the caller's default | ERROR |
| Degraded, declared with the wrong type | the caller's default, warning logged | ERROR |
Two things never happen: an evaluation does not throw because a provider is unwell, and a live value is never overridden by a declaration.
Here is that table as observed output. examples/flagd.php runs against a real flagd instance which is stopped
part-way through:
9. new-checkout=true checkout-variant=blue rate-limit-rpm=500 <- flagd answering
10. new-checkout=false checkout-variant=control rate-limit-rpm=100 <- flagd killed; declared defaults
13. new-checkout=false checkout-variant=control rate-limit-rpm=100
[circuit] flagd is now open <- breaker trips
14. new-checkout=false checkout-variant=control rate-limit-rpm=100 <- provider no longer called at all
The ordering matters: declared defaults appear at once, before the circuit opens. The fallback handles each individual failure; the breaker then stops the wasted network attempts. Two mechanisms, doing different jobs.
./dev example flagd # starts flagd + redis and runs it
Declaring flags
withDefaults() is the single source of truth for what a flag means when there is no live answer.
->withDefaults([ 'new-checkout' => false, // bool 'variant' => 'control', // string 'rate-limit-rpm' => 100, // int 'timeout' => 30, // int is accepted for a float flag 'config' => ['a' => 1], // array (OpenFeature "object") ])
Declarations are validated when the stack is built, not at first use — a malformed map fails your deploy rather
than surprising you mid-incident. Rejected: non-string keys, keys with surrounding whitespace, null, and
objects.
An undeclared flag throws. FlagEvaluator::isEnabled('typo') raises UndeclaredFlagException rather than
returning false, because a mistyped key that reads as "feature off" is indistinguishable from a deliberately
disabled feature, and nobody investigates a feature that looks correctly configured. The message suggests the
key you probably meant.
If you want per-call defaults, use the OpenFeature Client directly — Flipster::client() returns one, and the
resilience decorators work identically on that path.
The circuit breaker
Flipster defines a small CircuitBreaker port and ships an adapter over
ackintosh/ganesha.
use Ackintosh\Ganesha; use Epignosis\Flipster\CircuitBreaker\GaneshaCircuitBreaker; $ganesha = Ganesha\Builder::withRateStrategy() ->adapter(new Ganesha\Storage\Adapter\Redis($redis)) ->failureRateThreshold(50) // percent ->minimumRequests(10) // read docs/breaker.md before choosing this ->timeWindow(30) // seconds ->intervalToHalfOpen(10) ->build(); $breaker = new GaneshaCircuitBreaker($ganesha);
Two things to know before configuring it, both covered in docs/breaker.md:
- Redis cannot use the count strategy. It is the only Ganesha adapter that does not support it, and it is the one you want for sharing state across PHP-FPM workers. So production means the rate strategy.
- The rate strategy will not trip on a quiet service. It ignores the failure rate until
minimumRequestshave been seen insidetimeWindow. Set that below the traffic your window genuinely sees, or the circuit never opens however dead the backend is.
A missing flag never trips the breaker. FLAG_NOT_FOUND means the provider answered correctly, and counting it
would let one mistyped key disable every flag in the system.
Testing your own code
The doubles ship in the runtime package, so your test suite can use them with no extra dependency:
use Epignosis\Flipster\Testing\ControllableCircuitBreaker; use Epignosis\Flipster\Testing\InMemoryProvider; use Epignosis\Flipster\Testing\RecordingProvider; $recorder = new RecordingProvider(new InMemoryProvider(['kill-switch' => false])); $breaker = new ControllableCircuitBreaker(); $flags = Flipster::for($recorder) ->withDefaults(['kill-switch' => true]) ->withBreaker($breaker, 'flags') ->evaluator(); $breaker->open('flags'); // pin an outage — no thresholds to reach $flags->isEnabled('kill-switch'); // true, the declared default $recorder->timesEvaluated('kill-switch'); // 0, the provider was never reached
InMemoryProvider · RecordingProvider · ThrowingProvider · ControllableCircuitBreaker.
See docs/testing.md.
Caching
Flipster does not cache, at any version. Caching belongs to the provider you selected, which owns the flag data's lifecycle and its invalidation signals; a cache bolted on from outside can only guess with a TTL.
There is a PHP-specific trap here worth knowing about — see docs/caching.md.
Documentation
| File | Description |
|---|---|
| docs/architecture.md | how the decorators compose, and why |
| docs/breaker.md | tuning, and the two traps above |
| docs/testing.md | the toolkit in detail |
| docs/caching.md | why this library does not cache |
| docs/migrating.md | adopting Flipster in a codebase already using the SDK |
Requirements
- PHP 8.1 – 8.5. 8.1 is end-of-life; it is supported so the library can drop into codebases that have not migrated yet, and you should upgrade.
open-feature/sdk ^2.3,ackintosh/ganesha ^4.0,psr/log. No concrete provider is bundled.
Contributing
Docker is the only prerequisite — no PHP or Composer on your machine.
./dev check # style, static analysis, tests
See CONTRIBUTING.md.
License
MIT. See LICENSE.