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.
Install
Add the avsbhq/avsb package with Composer.
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.
Register the services
Declare AvsbServer in config/services.yaml with your SDK key, and tag the shipped listener so it runs on kernel.request.
Inject the server into a controller
Constructor injection resolves by type, because the service id is the class name.
Read a flag
Every read takes the evaluation context as its third argument and returns a Flag object, never a bare value.
Track a conversion
Call track() with the same context. Exposures are automatic, so experiment results appear without any extra call.
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:
composer require avsbhq/avsbVersion 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 lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
Add your SDK key to .env:
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnAn 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:
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 }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.
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:
<?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, ]); }}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.
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'],]);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:
$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_readyThe 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
// 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);$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:
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]],));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.
$this->avsb->flushEvents(); // send now instead of waiting for shutdown$this->avsb->pendingEventCount(); // events queued and not yet sentThe 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:
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,));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.
<?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); }}For functional tests, point the container at a fixture datafile in config/services_test.yaml so the whole application serves known values:
services: Avsbhq\Avsb\AvsbServer: autowire: false public: true factory: ['Avsbhq\Avsb\TestUtils\InMemoryDatafileLoader', 'fromFile'] arguments: ['%kernel.project_dir%/tests/fixtures/datafile.json']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
- Laravel integration: the same package, wired through the Laravel container.
- Multi-context targeting: combine user and organization attributes in one evaluation.
- Sticky bucketing: keep a visitor on the variation they first saw.
- Credentials: all four A vs B credentials and which one to reach for.