Symfony

This guide targets Symfony 6.4 or later on PHP 8.2 or later. The avsbhq/avsb package is a plain PHP SDK: you register AvsbServer as a service, autowire it into controllers and services, and read typed flags with an evaluation context. The package also ships a kernel.request listener that puts a context on every request for you.

1

Install

Add the avsbhq/avsb package with Composer.

2

Add your SDK key

Open your A vs B project and click Environments in the sidebar. Copy the SDK key for the environment this deployment talks to. Put it in .env and reference it as a container parameter.

3

Register the services

Declare AvsbServer in config/services.yaml with your SDK key, and tag the shipped listener so it runs on kernel.request.

4

Inject the server into a controller

Constructor injection resolves by type, because the service id is the class name.

5

Read a flag

Every read takes the evaluation context as its third argument and returns a Flag object, never a bare value.

6

Track a conversion

Call track() with the same context. Exposures are automatic, so experiment results appear without any extra call.

7

Test without the network

Build a datafile in the test and construct a server from it. No network, no cache, no events.

Install the package:

Shell
composer require avsbhq/avsb
Shell1 line
Publishing in progress

Version 1.0.1 of avsbhq/avsb is on its way to Packagist with this release. Until it arrives there, composer require reports that it cannot find the package.

Open your A vs B project and click Environments in the sidebar. It sits on its own there, next to Settings, not inside it. Each environment card shows a masked SDK key with Reveal and Copy buttons.

Environments is its own item in the sidebar. Click Reveal, then Copy, to get the SDK key for this environment.
  1. Environments lives in the sidebar on its own, not inside Settings.
  2. Click Reveal to see the full key, then Copy to copy it.
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Add your SDK key to .env:

Shell
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
Shell1 line

An SDK key is sdk_, then the environment name, then a generated id. The SDK checks that shape when you construct it, so a pasted dashboard URL or a truncated copy is reported at boot instead of turning into a 404 later.

Register the services in config/services.yaml:

YAML
services:    Avsbhq\Avsb\AvsbServer:        autowire: false        arguments:            $sdkKey: '%env(AVSB_SDK_KEY)%'    # Optional. Builds an EvalContext on every main request and sets it on the    # request as 'avsb_context'.    Avsbhq\Avsb\Middleware\Symfony\EventListener:        autowire: false        arguments:            $server: '@Avsbhq\Avsb\AvsbServer'        tags:            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
YAML14 lines

autowire: false is deliberate on both definitions: AvsbServer takes a string first argument that autowiring cannot guess, and passing every argument by name keeps the wiring readable.

The shipped Bundle is not registered in config/bundles.php

Avsbhq\Avsb\Middleware\Symfony\Bundle and its AvsbExtension are written to compile without symfony/framework-bundle in the dependency graph, so neither implements Symfony's BundleInterface or ExtensionInterface. The kernel cannot boot a bundle that does not implement BundleInterface, so register the two services above instead of adding the bundle to config/bundles.php. The service definitions do the same job: the extension's only two settings are sdk_key and datafile_path.

Inject the server wherever you need flags. The service id is the class name, so constructor injection resolves by type:

PHP
<?phpnamespace App\Controller;use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\EvalContext;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Routing\Attribute\Route;final class CheckoutController extends AbstractController{    public function __construct(private readonly AvsbServer $avsb) {}    #[Route('/checkout', name: 'checkout')]    public function show(Request $request): JsonResponse    {        // Prefer the signed-in identity. The listener's context is keyed by the        // session id, which is right for anonymous traffic and wrong once the        // same person signs in on a second device.        $user = $this->getUser();        $context = $user !== null            ? EvalContext::user($user->getUserIdentifier(), ['plan' => 'pro'])            : $request->attributes->get('avsb_context');        $checkout = $this->avsb->getBoolFlag('checkout_v2', false, $context);        $theme = $this->avsb->getStringFlag('ui_theme', 'default', $context);        return $this->json([            'showNewCheckout' => $checkout->value,            'variation' => $checkout->variationKey,            'theme' => $theme->value,        ]);    }}
PHP36 lines

The context is not optional in practice. Every caller who omits it buckets into the same variation, which makes experiment data meaningless, so the SDK warns once per process and names the call to add.

PHP
use Avsbhq\Avsb\EvalContext;// One kind, with targeting attributes of any JSON type$context = EvalContext::user('alice', ['plan' => 'pro', 'betaTester' => true]);// Any other single kind$org = EvalContext::user('acme-corp', ['tier' => 'enterprise'], 'organization');// Several kinds at once$context = EvalContext::multi([    'user' => ['key' => 'alice', 'plan' => 'pro'],    'organization' => ['key' => 'acme', 'tier' => 'enterprise'],]);
PHP13 lines

What a read gives you back

Every getter returns a Flag. All of its properties are public and readonly, so there are no getters to remember:

PHP
$flag = $this->avsb->getBoolFlag('checkout_v2', false, $context);$flag->value;          // mixed             the evaluated value, your type$flag->variationKey;   // string|null       null for default, not_found, not_ready$flag->source;         // EvaluationSource  why this value, a backed enum$flag->ruleId;         // string|null       the rule or holdout that decided$flag->reasons;        // list<string>      the decision trail, in order$flag->isEnabled();    // bool              a real decision AND a truthy value$flag->exists();       // bool              false for not_found and not_ready
PHP9 lines

The four getters are getBoolFlag, getStringFlag, getNumberFlag, and getJsonFlag. Each takes (string $key, $defaultValue, ?EvalContext $context = null) and each requires the default. You get that default back when the flag is missing, when the flag is off, or when no datafile ever loaded.

The getters do not coerce and do not throw. $flag->value is whatever the variation holds, so calling getBoolFlag on a flag whose variations hold strings gives you the string. Read $flag->value when you want the value and $flag->isEnabled() when you want a gate.

Tracking conversions

PHP
// AvsbServer::track(string $eventName, EvalContext $context, ?float $revenue = null, ?float $value = null): void$this->avsb->track('signup_completed', $context);$this->avsb->track('checkout_completed', $context, revenue: 49.99);$this->avsb->track('items_per_order', $context, value: 3.0);
PHP4 lines

$eventName is the metric key from your dashboard. An unknown key is stored, so a typo shows up as an empty metric rather than an error. $revenue is money in decimal major units. $value is the number for average-value metrics.

Purchases are typed and send immediately rather than waiting for a batch:

PHP
use Avsbhq\Avsb\PurchaseOrder;$this->avsb->trackPurchase($context, new PurchaseOrder(    orderId: 'ORD-1001',    total: 99.95,    currency: 'USD',    items: [['sku' => 'SKU-1', 'price' => 99.95, 'quantity' => 1]],));
PHP8 lines

Exposures are automatic. Every A/B test, bandit, and holdout decision records one, so nothing extra is needed to make experiment results appear.

Flushing events

Queued events are sent once, after the response, from a PHP shutdown function that the SDK registers on the first queued event. There is no kernel.terminate listener to add and nothing to configure.

PHP
$this->avsb->flushEvents();       // send now instead of waiting for shutdown$this->avsb->pendingEventCount(); // events queued and not yet sent
PHP2 lines
Long-lived workers

The PHP SDK has no background refresh thread. It loads the datafile when you construct it, through a cache in the system temp directory with a 60 second time to live, which suits PHP-FPM where each request builds a server and reads a warm file. A process that lives for hours (a Messenger worker, a Swoole or FrankenPHP runtime) keeps the datafile it loaded at construction, so refresh it with replaceDatafile() on a schedule you own, or run the A vs B agent and construct with AvsbServer::fromDatafile($datafile).

Configuration options

Everything optional lives in one typed object with named arguments, so a definition that needs more than an SDK key uses a factory:

PHP
use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\AvsbServerOptions;use Avsbhq\Avsb\Http\Psr18Transport;$server = new AvsbServer($sdkKey, new AvsbServerOptions(    logger: $psr3Logger,                 // defaults to stderr in dev, silent in prod    datafileTtlSeconds: 30,              // the floor on how fast a flag change lands    fetchTimeoutSeconds: 2.0,    transport: new Psr18Transport($client, $requestFactory, $streamFactory),    stickyBucketService: $redisStickyStore,));
PHP11 lines

Passing a PSR-18 transport sends A vs B requests through the HTTP client you already have, so your middleware, proxy settings, and instrumentation apply to them too.

Testing

Build a datafile in the test and construct a server from it. InMemoryDatafileLoader returns a server with events and heartbeats switched off, so a suite never touches the network by accident.

PHP
<?phpnamespace App\Tests;use Avsbhq\Avsb\EvalContext;use Avsbhq\Avsb\TestUtils\InMemoryDatafileLoader;use PHPUnit\Framework\TestCase;final class CheckoutFlagTest extends TestCase{    public function testTheRuleServesTheOnVariation(): void    {        $server = InMemoryDatafileLoader::fromJson(<<<'JSON'        {          "version": 2,          "sdkKey": "sdk_production_xxxxxxxxxxxxxxxx",          "flags": [{            "id": "flag_checkout",            "key": "checkout_v2",            "type": "boolean",            "enabled": true,            "defaultVariationId": "var_off",            "variations": [              {"id": "var_off", "key": "off", "value": false},              {"id": "var_on",  "key": "on",  "value": true}            ],            "overrides": [],            "rules": [{              "id": "rule_all",              "type": "targeted_delivery",              "enabled": true,              "audienceIds": [],              "hashAttribute": "user.key",              "trafficAllocation": null,              "variations": [{"variationId": "var_on", "percentage": 1}]            }]          }],          "audiences": []        }        JSON);        $flag = $server->getBoolFlag('checkout_v2', false, EvalContext::user('alice'));        self::assertTrue($flag->value);        self::assertTrue($flag->isEnabled());        self::assertSame('rule', $flag->source->value);    }}
PHP48 lines

For functional tests, point the container at a fixture datafile in config/services_test.yaml so the whole application serves known values:

YAML
services:    Avsbhq\Avsb\AvsbServer:        autowire: false        public: true        factory: ['Avsbhq\Avsb\TestUtils\InMemoryDatafileLoader', 'fromFile']        arguments: ['%kernel.project_dir%/tests/fixtures/datafile.json']
YAML6 lines

InMemoryDatafileLoader::fromArray, fromJson, and fromFile all return a ready server. loadArray returns the raw datafile array when you want to mutate a fixture before handing it over.

What's next

Was this helpful?