firefly/feature-flags is LaraFly's feature-flag subsystem, built on OpenFeature: flags are
flagd flag definitions, evaluated in-process by a PHP port of flagd's evaluator, and an
application gates code with an attribute, a route middleware, a Blade directive or a facade call. Operators change
flags at runtime from the admin dashboard, the flags actuator endpoint or php artisan firefly:flags,
with an audit trail, and every PHP-FPM worker sees the change within the source's refresh interval.
The same flag document evaluates to the same value, variant and reason in LaraFly and in PyFly, and a user lands in the same percentage bucket in both: the two frameworks share one contract and run the same conformance files in their test suites.
The checked-in Lumen sample exercises the facade, a gated route, shared targeting vectors, test overrides, store transactions and preview exposure behavior. Both editions of larafly by example develop that sample in Chapter 13A.
Switch the subsystem on and declare flags in config/firefly.php:
return [
'feature-flags' => [
'enabled' => true,
'flags' => [
'new-checkout' => false,
'checkout-flow' => 'v2',
],
],
];true and false are shorthand for a boolean flag with the variants on and off; a string is shorthand for a
flag with that single variant. Then ask for a flag wherever you need it:
use Firefly\FeatureFlags\FeatureFlags;
final class CheckoutPage
{
public function __construct(private readonly FeatureFlags $flags) {}
public function template(): string
{
if (! $this->flags->isEnabled('new-checkout')) {
return 'checkout.legacy';
}
return $this->flags->getString('checkout-flow', 'v1') === 'v2' ? 'checkout.v2' : 'checkout.v1';
}
}FeatureFlags has a typed getter per flag type — isEnabled(), getString(), getInt(), getFloat(),
getObject() — plus variant() and details(), which returns the value, the variant, the reason, the error code
and the flag's metadata. Every getter takes the default to answer when the flag is missing or fails, and an optional
evaluation context and targeting key. Under the facade sits a named OpenFeature client (Client is a bean too), so
code written against the OpenFeature PHP SDK works unchanged.
A flag is a flagd flag definition: a state, the variants it can answer, the defaultVariant, optional
targeting rules in JSON Logic, and optional metadata.
{
"flags": {
"new-checkout": {
"state": "ENABLED",
"variants": {"on": true, "off": false},
"defaultVariant": "off",
"targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]},
"metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}
}
},
"$evaluators": {"is-beta": {"in": ["beta", {"var": "roles"}]}}
}- Keys match
^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$; they appear in URLs and CLI arguments. - Variants all hold one JSON type: boolean, string, number, or object.
- Targeting returns a variant name, or
nullto fall back todefaultVariant. The operators are flagd's: JSON Logic plusfractional,sem_ver,starts_withandends_with;{"$ref": "is-beta"}reuses a shared rule from$evaluators. - Metadata values are scalars. Four keys are reserved:
description,owner,kind(release,experiment,opsorpermission) andexpires(YYYY-MM-DD). A flag past itsexpiresdate still evaluates; it is flagged as expired in the admin page, the actuator, the health details, and one warning per day in the log. - A field flagd does not define is kept as written and ignored.
An invalid definition in flags or in a flag file refuses the boot, naming the key and the reason (for example
Invalid feature flag [bad key] from source [config]: invalid flag key.); an invalid document from the sync
endpoint or the store is rejected as a whole and the last good one kept.
After a file has loaded successfully, a later invalid edit also leaves its last good document in force. Replace a
file atomically (write a sibling file, then rename it) so a polling worker never parses a partial write. Quote YAML
date-like keys and values: YAML parsers otherwise disagree about dates and non-string keys that JSON cannot express.
Every recipe is a flag definition you can put in flags, a flag file or the store.
Release toggle and kill switch. A boolean flag, off by default; turning it on (or a DISABLED state, which
makes every gate answer its default) needs no deploy once the store is enabled.
{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}Progressive rollout. fractional hashes the flag key and the targeting key (the signed-in principal's name),
so a user stays in their bucket as the percentages move, in PHP and in Python alike. Anonymous traffic has no
targeting key and gets the default variant.
{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off",
"targeting": {"fractional": [["on", 10], ["off", 90]]}}Beta cohort. roles holds the principal's ROLE_ authorities without the prefix.
{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off",
"targeting": {"if": [{"in": ["BETA", {"var": "roles"}]}, "on", null]}}Per-tenant or per-plan entitlement. tenant comes from the principal attribute named by
firefly.feature-flags.context.tenant-attribute; plan comes from a context contributor of your own (below).
{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off",
"targeting": {"if": [{"or": [{"in": [{"var": "tenant"}, ["acme", "globex"]]}, {"==": [{"var": "plan"}, "enterprise"]}]}, "on", null]}}Multivariate experiment. A weighted split with a holdout; switch firefly.feature-flags.events.evaluations on to
publish a FeatureFlagEvaluated event per evaluation — the exposure record the experiment's analysis needs.
{"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control",
"targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]},
"metadata": {"kind": "experiment", "owner": "growth"}}On a bean. #[FeatureFlag] on a method (or a class: every public method) of any stereotyped bean. While the
flag is off, the call answers the fallback method — called with the same arguments — or throws
FeatureFlagDisabledException. variant: gates on one variant; default: true opens the gate when the flag is
missing.
<?php
declare(strict_types=1);
namespace Lumen\Application;
use Firefly\Container\Attributes\Service;
use Firefly\FeatureFlags\Gating\FeatureFlag;
#[Service]
class WalletRolloutGate
{
#[FeatureFlag('wallet-premium-label', fallback: 'legacyLabel')]
public function label(string $name): string
{
return "Welcome back, {$name}";
}
public function legacyLabel(string $name): string
{
return "Hello {$name}";
}
}The attribute rides the proxy every advice shares, at advice order 80: inside the #[Timed] metric (50), so a
refused call is still measured, and outside method security (100), resilience (200) and the transaction (1000), so a
dark feature never spends an authorization check, a retry or a transaction. php artisan firefly:cache compiles it
like every other advice. The class must not be final; the scanner refuses a placement no proxy could enforce
(a final class, a static method, a fallback that does not exist) with a sentence naming the method.
On a route. On a controller action the attribute becomes route middleware, so a dark endpoint answers before its body is bound or validated:
<?php
declare(strict_types=1);
namespace Lumen\Web;
use Firefly\FeatureFlags\Gating\FeatureFlag;
use Firefly\Web\Attributes\GetMapping;
use Firefly\Web\Attributes\RestController;
#[RestController]
class WalletRolloutController
{
/** @return array{label: string} */
#[FeatureFlag('wallet-rollout-route')]
#[GetMapping('/api/v1/wallet-rollout')]
public function show(): array
{
return ['label' => 'Available balance'];
}
}Routes declared in route files use the same middleware by its alias, feature-flag:{key}[,{variant}]:
A gated route that is off answers firefly.feature-flags.web.disabled-status — 404 (the default: a dark launch looks
like a page that does not exist), 403 or 503 — as problem+json, and never names the flag.
In a template.
@featureflag('new-checkout')
@include('checkout.v2')
@else
@include('checkout.legacy')
@endfeatureflag
@featurevariant('checkout-flow', 'v2')
<p>Try the faster checkout.</p>
@endfeaturevariantEvery gate fails closed on a missing flag, an evaluation failure, a type mismatch or a switched-off subsystem:
it answers the gate's default, which is "off" unless the gate says default: true. A source failure after a
successful load retains the last-good document, so a previously enabled gate can remain open during an outage.
A flag never decides which beans exist:
bean conditions are evaluated once at boot, so a boot-time switch is a property — use #[ConditionalOnProperty].
The framework builds a context for every evaluation, and the caller's own attributes win over it:
| Attribute | Value |
|---|---|
targetingKey |
the authenticated principal's name; absent when anonymous |
roles |
the principal's ROLE_ authorities, without the prefix |
tenant |
the principal attribute named by firefly.feature-flags.context.tenant-attribute |
application |
app.name |
profiles |
the active Firefly profiles |
Add your own attributes with a context contributor — any #[Component] implementing
EvaluationContextContributor; #[Order] sets the order, and a later contributor may override an earlier one:
use Firefly\Container\Attributes\Component;
use Firefly\FeatureFlags\Context\EvaluationContextBuilder;
use Firefly\FeatureFlags\Context\EvaluationContextContributor;
#[Component]
final class PlanContributor implements EvaluationContextContributor
{
public function __construct(private readonly Subscriptions $subscriptions) {}
public function contribute(EvaluationContextBuilder $context): void
{
$customer = $context->targetingKey();
if ($customer !== null) {
$context->set('plan', $this->subscriptions->planOf($customer));
}
}
}An explicit context overlays all contributors. targetingKey in JSON context accepts a nonempty string or an
integer converted to decimal text; a separate nonempty targeting-key argument wins over it. LaraFly refuses an
Eloquent model, Arrayable or Jsonable object as a targeting key: pass the user's stable id. It drops unsupported
attribute values and numeric-looking attribute names with a DEBUG diagnostic. A DateTimeInterface value becomes
UTC epoch milliseconds, as in PyFly.
Flags come from up to four sources, lowest precedence first: config (flags and evaluators) → a watched
file → another service's http sync endpoint → the writable store. The highest source that defines a key
supplies its whole definition; the admin page and the actuator show which source won (origin) and which it
shadows (overrides).
return [
'feature-flags' => [
'enabled' => true,
'sources' => [
'file' => ['enabled' => true, 'path' => base_path('flags.yaml'), 'refresh-interval' => '5s'],
'http' => ['enabled' => true, 'url' => 'https://control-plane.internal/feature-flags/flagd.json', 'token' => env('FLAGS_TOKEN'), 'refresh-interval' => '30s', 'timeout' => '2s'],
'store' => ['enabled' => true, 'driver' => 'database', 'connection' => null, 'refresh-interval' => '5s'],
],
],
];PHP-FPM shares nothing between requests, so the registry keeps each source's last good document and its revision
in the application cache. A request re-checks a source only when its refresh-interval has elapsed — a file's
modification time and size, the store's newest change id, an HTTP conditional GET under a non-blocking lock (one
worker fetches; the others serve the last good document meanwhile) — and the request that sees a change publishes
FeatureFlagsChanged. A long-lived process (Octane, a queue worker) re-checks on the same schedule. When the cache
store is down, sources are read in-process and flags keep evaluating.
Give each application its own application-cache prefix and retain source entries without eviction; a fresh worker needs those entries to recover the last good file or remote document. A file revision uses whole-second mtime and size: same-size edits within the same timestamp can remain unseen indefinitely, even after atomic rename. Publish atomically and advance the file's mtime to a distinct second (or change its size); do not rely on rename alone.
Change events are best effort, not durable or exactly once. Rolling workers with different configuration can
alternate shared change announcements. Cache outages suspend shared announcements; recovery with no composed
record reports startup. Use the store audit history when you need a durable record of operator writes.
The store is the writable layer behind the admin page, the actuator endpoint and the CLI. A write replaces the
key's definition for every lower source until it is deleted; every write also appends a change row (who, what,
when), and a write may carry expectedVersion so two operators cannot overwrite each other.
The relational tables keep exact-case keys, JSON payloads and zoneless UTC microsecond timestamps across SQLite,
PostgreSQL, MySQL and MariaDB. A write inside the caller's transaction remains pending until that caller commits;
rollback removes both the definition and audit row. If MariaDB aborts an existing snapshot after a competing insert,
the caller must roll back and retry its complete operation. A post-commit local refresh failure preserves the
committed write and the last good local composition.
With the database driver, publish and run the migration:
php artisan vendor:publish --tag=firefly-feature-flags-migrations
php artisan migrateThe migration is also loaded automatically while the store is enabled with the database driver. It creates
firefly_feature_flags and firefly_feature_flag_changes, the same tables PyFly creates: two services that share
a database share their flags, and either framework reads the rows the other wrote. connection picks the Laravel
connection (null is the default one). The memory driver keeps flags per process, for tests and demos.
Actuator. GET /actuator/flags lists the provider, the sources and every flag; GET /actuator/flags/{key}
describes one, with each source's definition and the latest 50 changes; POST /actuator/flags/{key} evaluates
({"action": "evaluate", "context": {…}, "targetingKey": "…"}) or writes (enable, disable,
default-variant, put, delete). The answers are the same JSON PyFly answers, and a refusal is
{"error": code, "message": text} with a status per code: writes-disabled 403, not-writable 409,
invalid-definition 422, unknown-flag 404, unknown-variant 422, conflict 409, bad-request 400.
The sensitive /actuator/flags endpoint answers 404 until flags is named in firefly.management.endpoints.web.exposure.include, and
writes additionally need firefly.feature-flags.management.writes and a store:
return [
'management' => [
'endpoints' => ['web' => ['exposure' => ['include' => 'health,info,flags']]],
],
'feature-flags' => [
'enabled' => true,
'sources' => ['store' => ['enabled' => true]],
'management' => ['writes' => true],
],
];curl -s http://localhost:8000/actuator/flags
curl -s -X POST http://localhost:8000/actuator/flags/new-checkout -H 'Content-Type: application/json' -d '{"action":"enable"}'
curl -s -X POST http://localhost:8000/actuator/flags/new-checkout -H 'Content-Type: application/json' -d '{"action":"evaluate","targetingKey":"ada"}'The actor recorded for a write is the authenticated principal's name, or actuator when there is none. An
evaluate is a preview: it uses the context you send plus the application's own attributes — never yours — and
counts in no metric and no exposure record.
After a successful put, {"key":"…","refreshPending":true} means no new local definition is visible yet;
it does not claim a caller-owned transaction has committed. If an older last-good definition remains visible, the
response describes that current view. Poll the detail endpoint or wait for the source refresh before assuming the
local process has adopted the new revision.
Admin. The admin dashboard's Feature flags page lists every flag with its state, origin and
expiry, toggles one, edits its definition as JSON, sets its default variant, previews an evaluation and shows its
history. Its writes follow the same rules and record the actor admin when nobody is signed in.
CLI.
php artisan firefly:flags list
php artisan firefly:flags show new-checkout --json
php artisan firefly:flags evaluate checkout-flow --context='{"plan":"pro"}' --targeting-key=ada
php artisan firefly:flags enable new-checkout --expected-version=0
php artisan firefly:flags disable new-checkout --expected-version=1
php artisan firefly:flags default-variant checkout-flow v2
php artisan firefly:flags put banner --file=banner.json
php artisan firefly:flags delete bannerA command-line write records cli:<os-user>.
For evaluate, omit --context or pass --context='{}' when there are no explicit attributes. If supplied,
--context must contain a JSON object; empty text, JSON null, arrays, scalars and malformed JSON fail with
bad-request and exit 1 before any evaluation. Add --json to receive the portable error body.
One service can serve its effective flag set to others — a control plane serving every service of a product, in
either framework. The server answers GET on its path with the composed flagd document, an ETag, and 304 for a
matching If-None-Match; it requires Authorization: Bearer <token>, and enabling it without a token refuses the
boot unless allow-anonymous is set.
return [
'feature-flags' => [
'enabled' => true,
'server' => ['enabled' => true, 'path' => '/feature-flags/flagd.json', 'token' => env('FLAGS_SERVER_TOKEN')],
],
];A consumer points its http source at that URL with the same token (see Sources). Test
overrides are never served.
- Metric:
feature_flag_evaluations_total{flag, variant, reason}on the meter registry when Observability is installed andfirefly.observability.metrics.enabledis on (the default). When metrics are disabled or no meter is available, feature flags useNoOpFeatureFlagMetrics; evaluation still works but no counter is recorded.variantisnonewhen there is none, and a failed evaluation counts withreason="ERROR"when the meter is active. - Health: the
featureflagscomponent is UP while every source has loaded at least once, DOWN while one never has, and lists each source's status, the flag count and the expired flags. - Events:
FeatureFlagsChanged(changedKeys, origin)when the effective set changes,FeatureFlagUpdatedafter a committed store write, and — withfirefly.feature-flags.events.evaluations—FeatureFlagEvaluatedper evaluation.
use Firefly\FeatureFlags\Event\FeatureFlagEvaluated;
use Illuminate\Support\Facades\Event;
Event::listen(FeatureFlagEvaluated::class, function (FeatureFlagEvaluated $exposure): void {
Exposures::record($exposure->key, $exposure->variant, $exposure->targetingKey);
});Telemetry never changes an evaluation: a failing meter or listener is logged at debug and the flag's value stands. When an exposure value contains more than 10,000 value occurrences, the exposure event is omitted, while the evaluation result and metric remain. The budget counts the root and every nested container or scalar, including repeated references; a cycle also exhausts the budget.
An application may install a different OpenFeature provider for evaluation. Firefly's registry, source history,
writable store and management operations then have no Firefly document to operate on; keep operator expectations
limited to that provider's own capabilities. The PHP SDK does not expose Firefly's flag metadata, so use
FeatureFlags::details() when metadata matters.
withFeatureFlags() overrides flags of the running application for the rest of a test, above every source,
shorthand included; see Testing.
it('shows the faster checkout to the treatment group', function (): void {
withFeatureFlags(['new-checkout' => true, 'checkout-flow' => 'v2']);
$this->get('/checkout')->assertSee('Try the faster checkout.');
});Every key, as the configuration reference documents it:
'feature-flags' => [
'enabled' => env('FIREFLY_FEATURE_FLAGS_ENABLED', false),
// 'flags' => [
// 'new-checkout' => false,
// 'checkout-flow' => [
// 'state' => 'ENABLED',
// 'variants' => ['control' => 'v1', 'treatment' => 'v2'],
// 'defaultVariant' => 'control',
// 'targeting' => ['fractional' => [['control', 50], ['treatment', 50]]],
// 'metadata' => ['owner' => 'payments', 'kind' => 'experiment', 'expires' => '2099-12-31'],
// ],
// ],
// 'evaluators' => [
// 'is-beta' => ['in' => ['beta', ['var' => 'roles']]],
// ],
// …
// 'sources' => [
// 'file' => ['enabled' => false, 'path' => '', 'refresh-interval' => '5s'],
// 'http' => ['enabled' => false, 'url' => '', 'token' => env('FIREFLY_FEATURE_FLAGS_HTTP_TOKEN', ''), 'refresh-interval' => '30s', 'timeout' => '2s'],
// 'store' => ['enabled' => false, 'driver' => 'database', 'connection' => null, 'refresh-interval' => '5s'],
// ],
// The principal attribute that holds the tenant id (the `tenant` evaluation-context attribute).
// 'context' => ['tenant-attribute' => 'tenant'],
// Status of a #[FeatureFlag]-gated route or method when the flag is off: 404, 403 or 503.
// 'web' => ['disabled-status' => 404],
// Publish a FeatureFlagEvaluated event per evaluation (exposure records for experiments).
// 'events' => ['evaluations' => false],
// Allow writes from the admin page, POST /actuator/flags/{key} and php artisan firefly:flags.
// 'management' => ['writes' => false],
// Serve this application's effective flag set to other services (bearer token required unless
// allow-anonymous is true; enabling it without a token refuses the boot).
// 'server' => ['enabled' => false, 'path' => '/feature-flags/flagd.json', 'token' => env('FIREFLY_FEATURE_FLAGS_SERVER_TOKEN', ''), 'allow-anonymous' => false],
],Key (under firefly.feature-flags) |
Default | Meaning |
|---|---|---|
enabled |
false |
the master switch |
flags |
[] |
inline definitions, flagd or shorthand |
evaluators |
[] |
shared $evaluators rules |
sources.file.enabled / .path / .refresh-interval |
false / '' / 5s |
a watched flagd document (.json, .yaml, .yml) |
sources.http.enabled / .url / .token / .refresh-interval / .timeout |
false / '' / '' / 30s / 2s |
the sync client |
sources.store.enabled / .driver / .connection / .refresh-interval |
false / database / null / 5s |
the writable layer |
context.tenant-attribute |
tenant |
the principal attribute holding the tenant id |
web.disabled-status |
404 |
the status of a gated route or method that is off: 404, 403 or 503 |
events.evaluations |
false |
publish FeatureFlagEvaluated |
management.writes |
false |
allow writes from the admin page, the actuator and the CLI |
server.enabled / .path / .token / .allow-anonymous |
false / /feature-flags/flagd.json / '' / false |
the sync server |
- Every gate is closed.
firefly.feature-flags.enabledis off, or the key is misspelt: gates fail closed. The actuator'sGET /actuator/flags/{key}answersunknown-flagfor a key no source defines. - A change does not show. Each source is re-checked once per
refresh-interval; the writing process sees its own write when its local refresh succeeds. If that refresh is deferred or fails, the committed write remains durable and normal polling restores local visibility. A store write made with the cache store down is seen by other workers when their own interval elapses. - The boot fails with
Invalid feature flag [...]. A definition inflagsor in the flag file breaks a rule above; the message names the key, the source and the rule. - The boot rejects a source or sync server setting. An enabled file source needs a path. An enabled HTTP source
needs a URL and a positive
refresh-interval; zero is allowed for the cheaper file and store checks. An enabled sync server needs a token unlessallow-anonymousis true, and its path cannot be empty or/. - Sync returns 401/403 before the controller. The sync controller's bearer token does not bypass application Security HTTP/JWT filters. Configure the sync path explicitly in the application's security rules so its intended machine client can reach the controller, and retain the controller's own token check. Inspect both filter and controller responses before changing credentials.
writes-disabled/not-writable. Setfirefly.feature-flags.management.writes, and enable the store.- A percentage rollout ignores anonymous users. They have no targeting key; contribute a stable anonymous id
as
targetingKeyfrom a context contributor, or target on another attribute. #[FeatureFlag]refuses the boot. The scanner names the method and the reason: removefinal, move the attribute to a public instance method, or name a fallback that exists.