PHP SDK

Evaluate flags in PHP-FPM, Laravel and Symfony apps without a request ever waiting for featflags once your servers have the flags.

The PHP SDK evaluates flags in your process, so your users’ attributes never leave your servers. PHP has no long-lived process to keep flags in, so the SDK keeps them in a store every PHP process on the server shares, APCu by default, and a request only ever reads it. It needs PHP 8.2 or later with the curl and json extensions, and nothing else.

Install

composer require featflags/featflags
composer require featflags/openfeature-provider   # optional: the OpenFeature provider
composer require featflags/otel                   # optional: the OpenTelemetry hook

The packages are on Packagist.

Create a client

Create one client per environment key per process, as a service in your container (a Laravel singleton, a Symfony service), not one per evaluation. On a server, use the environment’s server key (sk_…, under Environment → Keys), which reaches every flag; keep it secret.

$flags = new Featflags\Client(
    getenv('FEATFLAGS_KEY'), // sk_…
    'https://flags.featflags.io',
    eventsUrl: 'https://events.featflags.io',
);

Without eventsUrl the client sends nothing back: no evaluation counts for the flag’s page, and no experiment data.

Argument Default What
eventsUrl off Where evaluation counts, experiment exposures and track events go.
pollInterval 5 s for sk_… keys, 60 s otherwise How often the stored flags are checked for changes, ±10% jitter. Zero or less fetches them once and keeps them.
flushInterval 60 s How often stored counts, exposures and events are sent. Exposures and events also go once 5,000 are waiting. The first send waits up to one extra interval at random.
onExposure none Called with a Featflags\Exposure the first time this client (under PHP-FPM, this request) evaluates each (context key, flag, variant).
bootstrap none Flags to evaluate against until the store has some, so no request waits for the first fetch: a Featflags\Document, or its JSON as text or decoded.
timeout 3 s HTTP timeout for fetching flags and sending counts, and the longest a cold start waits.
store APCu if enabled, else files Where processes share flags, counts and list shards. See Stores.
membershipTimeout 0.5 s How long one evaluation waits for list shards the store doesn’t have.
membershipWholeBytes 8 MB Lists totalling less than this are downloaded whole.
hooks none Featflags\Hooks run around every evaluation.
logger error_log Any PSR-3 logger. The SDK only logs warnings and errors.
deferWork true, except on the CLI Whether background work waits for the end of the request. See Long-running workers.
experimentKey none The environment’s experiment key, xk_…, for experiments.

Evaluate flags

Each typed method takes the flag key, a context and the fallback your code gets when the flag is missing, the context is invalid or the value has another type:

$user = ['key' => 'u-alice', 'plan' => 'pro', 'groups' => ['qa']];

if ($flags->boolVariation('new-checkout', $user, false)) {
    // …
}

$limit = $flags->numberVariation('upload-limit-mb', $user, 50);
$theme = $flags->stringVariation('theme', $user, 'light');
$config = $flags->jsonVariation('search-config', $user, []);

numberVariation returns an int or a float as the JSON has it. jsonVariation returns JSON objects as arrays.

To know why a context got a value, use the …Detail methods (boolVariationDetail, stringVariationDetail, …). They return a Featflags\Detail with value, variant, reason, and ruleId or prerequisite when they apply:

$d = $flags->stringVariationDetail('pricing-page', $user, 'v1');
$logger->info("pricing-page: {$d->value} ({$d->reason}, rule {$d->ruleId})");

Concepts lists the reasons.

Contexts

A context is an array (or an object, as json_decode gives one). key is required; kind defaults to user.

['key' => 'u-alice', 'email' => 'alice@acme.com']                         // a user
['kind' => 'org', 'key' => 'acme', 'plan' => 'team']                      // another kind
['kind' => 'multi', 'user' => ['key' => 'u-alice'], 'org' => ['key' => 'acme', 'plan' => 'team']]

Attribute values are strings, numbers, booleans or lists of those; null, objects, arrays with string keys and nested lists count as missing. An invalid context evaluates to the fallback with reason error.

To evaluate many flags for one context, parse it once with new Featflags\Context($array) and pass that. isValid() says whether it’s valid, kinds() lists its kinds and key($kind) returns a kind’s key. More on contexts in Concepts.

How it works under PHP-FPM

Under PHP-FPM every request starts with nothing, so the client shares what it needs through the store:

  • Flags. A request evaluates the stored flags and never waits for featflags. Once per poll interval, the first request to end after they went stale checks for changes, under a lock so only one process does. Newer flags replace them for every process; older ones never do, and a failed check keeps what’s stored.
  • Cold start. Only when nothing is stored yet does a request fetch during the request: one process fetches and the others wait for it, at most timeout (3 s). If featflags can’t be reached then, they serve your fallbacks with reason not_found and try again a poll interval later, so an outage at a cold start doesn’t hold up every request. With bootstrap, no request ever waits.
  • Counts, exposures and events. Each request counts in memory and adds to the store’s totals when it ends. The first request to end after the flush interval sends everything in one post.
  • List shards are stored too, so each is fetched once for every process.

That end-of-request work runs in a shutdown function the client registers. PHP-FPM only finishes the response once shutdown functions have run, unless the request called fastcgi_finish_request(). Laravel and Symfony call it when they send the response, so there no request waits for this work. In plain PHP, call fastcgi_finish_request() yourself before the end of the script, or one request per poll interval pays for a quick conditional request to featflags (usually a few milliseconds).

When the store is emptied

Restarting or reloading PHP-FPM, apcu_clear_cache(), and APCu running out of memory all empty an APCu store. The next request is then a cold start, the counts gathered since the last send (up to a minute’s worth) are lost, and list shards are fetched again. Nothing else is affected.

Give APCu room (apc.shm_size, 32 MB by default) for the flags, the shards of big lists, and, while experiments run, up to about 3 MB of exposures. Lists are only downloaded whole under membershipWholeBytes (8 MB), so a big list can’t fill APCu and empty it.

Long-running workers

RoadRunner, Swoole, FrankenPHP’s worker mode and queue workers handle many requests without ending the process, so shutdown functions don’t run between them. Pass deferWork: false, and the evaluation that finds work due does it, at most once per poll or flush interval. That’s the default on the CLI.

A script that runs once, such as a cron job, can call $flags->flush() before it exits. Otherwise what it counted waits in the store for the next process that ends after the flush interval.

Stores

Store Shared by Notes
Featflags\ApcuStore the workers of one PHP-FPM pool, or one Apache with mod_php The default when APCu is enabled. Separate CLI processes don’t share APCu.
Featflags\FileStore($dir) every process on the machine that can write $dir The default without APCu, under sys_get_temp_dir(). The directory is readable by its owner only, since a server key’s flags include the secret of private lists.

Pass one store to every client of a process. A queue worker and a web pool that share no APCu can share a FileStore. You can also implement Featflags\Store (five methods: get, set, delete, lock, unlock) over your framework’s cache; prefer one with real locks.

Lists and private segments

Targets and segment lists of up to 64 keys travel with the flags. Longer lists, up to millions of keys, are split into small shards, kept in the store once fetched (they never change):

  • When all of an environment’s lists total under membershipWholeBytes (8 MB), the first request to end after the flags arrive downloads every shard.
  • Otherwise an evaluation fetches the shard it needs the first time any process does.

An evaluation that needs a shard the store doesn’t have waits for it, at most membershipTimeout (0.5 s, shared by every list lookup in the evaluation). Past that, the list doesn’t match and the reason is membership_pending: the value is what the flag serves without that list. The request then stops waiting for that shard and fetches it when it ends, for the requests after it.

Private segments can’t be read by a browser on its own. Your backend computes a token per context kind and hands it to the browser at sign-in:

$tokens = $flags->membershipToken(['key' => $userId]); // ['user' => '8ecc…']

The browser passes them to identify(context, { membershipTokens }) in the TypeScript SDK. membershipToken is empty when the environment has no private lists, or for an invalid context.

Staying up to date

Stored flags are checked for changes every 5 seconds with a server key (60 seconds with a client key), and only downloaded when they’ve changed. featflags being unreachable never breaks a request that has stored flags: they keep being served. version() says which version of the flags is in use, or null before any.

refresh() fetches the flags now and flush() sends every process’s stored counts, exposures and events now. Both throw on failure (Featflags\HttpError, Featflags\DocumentError).

Experiments

To run experiments, give the client eventsUrl and the environment’s experiment key, xk_…. It’s shown once when you generate it; keep it with your other secrets, since featflags never holds it.

$flags = new Featflags\Client(getenv('FEATFLAGS_KEY'), 'https://flags.featflags.io',
    eventsUrl: 'https://events.featflags.io',
    experimentKey: getenv('FEATFLAGS_EXPERIMENT_KEY'));

$on = $flags->boolVariation('new-checkout', $user, false); // counts as an exposure if an experiment served it
$flags->track($user, 'checkout.completed', $order->total);  // an event key and an optional number
$flags->track($user, 'page.error');
  • An evaluation an experiment served is an exposure. Which exposures were sent is kept in the store, so a user who loads ten pages is one exposure, not ten.
  • track sends the event to every running experiment whose metrics count it, and nothing when none does, so tracking freely costs nothing. It doesn’t need the user to have been exposed here: the exposure often happened in the browser.
  • Event keys match [a-z0-9_.:-]{1,64}, and the value is an optional number below 10¹⁵. A bad key or value is logged once and dropped, never thrown.
  • Exposures and events are pooled in the store with the counts: they go at the next send, or as soon as 5,000 wait across all processes. The store holds at most 50,000; more are dropped, and the next send logs how many.
  • Users are sent as an ID made from their key with your experiment key, different in every experiment. Nothing else about them is sent.
  • After you rotate the experiment key, a deploy still holding the old one sends nothing, and logs it once.

Browsers and mobile apps shouldn’t hold the experiment key, since anything in an app is public. Give each signed-in user their own seeds instead, alongside their membership tokens:

$seeds = $flags->experimentSeeds($user); // ['fingerprint' => 'e178…', 'seeds' => ['user' => 'd457…']]; null without a key
return ['membershipTokens' => $flags->membershipToken($user), 'experimentSeeds' => $seeds];

The client SDKs take them in identify. How units and seeds are computed is in the SDK specification.

Hooks

A hook runs around every evaluation, for logging, metrics or tracing. Extend Featflags\Hook and override the stages you need: beforeEvaluation runs in the order hooks were added, afterEvaluation in reverse, with the detail the caller gets. A stage that throws is logged and skipped. Under PHP-FPM a client lives for one request, so hooks see that request’s evaluations.

final class Timer extends Featflags\Hook
{
    public function name(): string
    {
        return 'timer';
    }

    public function beforeEvaluation(Featflags\EvaluationSeries $series, array $data): array
    {
        return [...$data, 'start' => hrtime(true)];
    }

    public function afterEvaluation(Featflags\EvaluationSeries $series, array $data, Featflags\Detail $detail): array
    {
        error_log(sprintf('%s %s: %s in %d µs', $series->method, $series->flagKey, $detail->reason, (hrtime(true) - $data['start']) / 1000));

        return $data;
    }
}

$flags = new Featflags\Client($key, 'https://flags.featflags.io', hooks: [new Timer()]);

track runs no hooks. The full contract is in the SDK specification.

OpenTelemetry

The featflags/otel package adds a feature_flag.evaluation event, with OpenTelemetry’s semantic convention attributes, to the current span:

$flags = new Featflags\Client($key, 'https://flags.featflags.io', hooks: [new Featflags\Otel\OtelHook()]);

OpenFeature

The featflags/openfeature-provider package is a provider for the OpenFeature PHP SDK (open-feature/sdk):

use OpenFeature\OpenFeatureAPI;
use OpenFeature\implementation\flags\Attributes;
use OpenFeature\implementation\flags\EvaluationContext;

$api = OpenFeatureAPI::getInstance();
$api->setProvider(new Featflags\OpenFeature\Provider($flags));

$on = $api->getClient()->getBooleanValue('new-checkout', false, new EvaluationContext('u-alice', new Attributes(['plan' => 'pro'])));

The targeting key is the context key. Add a kind attribute for another kind, or 'kind' => 'multi' with each member as an array under its kind, each with its own targetingKey. Pass dates as RFC 3339 strings or epoch milliseconds: DateTime values count as missing. OpenFeature’s PHP SDK has no flag metadata, so use the client’s …Detail methods when you need the featflags reason or rule. How reasons map to OpenFeature’s is on the OpenFeature page.

Shutting down

Nothing to do: the client’s shutdown function hands its counts to the store when each request ends, and a later request sends them. A one-off CLI script can call flush() to send them at once.