Search by

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.

Maintainers

Package info

github.com/epignosis/flipster

pkg:composer/epignosis/flipster

Transparency log

Statistics

Installs: 56

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-05 04:08 UTC

This package is auto-updated.

Last update: 2026-08-26 09:53:08 UTC


README

CI PHP License

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 minimumRequests have been seen inside timeWindow. 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.