Laravel
This guide targets Laravel 10 or later on PHP 8.2 or later. The avsbhq/avsb package is a plain PHP SDK with a small Laravel layer: a route middleware that puts an evaluation context on every request, and a facade for static access. By the end you will resolve AvsbServer from the container, read typed flags in controllers, and record conversions.
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, then put it in .env.
Create config/avsb.php
The package ships no publishable config stub, so write the file yourself. It is four lines.
Register the SDK in the container
Bind AvsbServer as a singleton in a small provider of your own. One instance per request holds the datafile and batches that request's events.
Register the route middleware
The shipped middleware builds an EvalContext from the authenticated user (or the session id) and sets it on the request as avsb_context.
Read a flag in a controller
Every read takes the 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.
Create config/avsb.php. There is no vendor:publish provider to run: the package ships no config stub, so this file is yours.
<?phpreturn [ 'sdk_key' => env('AVSB_SDK_KEY', ''), // Optional. Point at a datafile another process keeps fresh (the A vs B // agent shape). When the file exists it wins and the SDK never fetches. 'datafile_path' => env('AVSB_DATAFILE_PATH', ''),];Bind the SDK in a provider of your own, app/Providers/AvsbServiceProvider.php:
<?phpnamespace App\Providers;use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\AvsbServerOptions;use Illuminate\Support\ServiceProvider;class AvsbServiceProvider extends ServiceProvider{ public function register(): void { $this->app->singleton(AvsbServer::class, function (): AvsbServer { $sdkKey = (string) config('avsb.sdk_key', ''); $path = (string) config('avsb.datafile_path', ''); if ($path !== '' && is_file($path)) { $datafile = json_decode( (string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR ); return new AvsbServer($sdkKey, new AvsbServerOptions(datafile: $datafile)); } // Constructing with an unusable key still resolves: the SDK logs // why and every flag returns your default, rather than the // container throwing during boot. return new AvsbServer($sdkKey); }); }}Register it the way your Laravel version does. On Laravel 11 and later:
// bootstrap/providers.phpreturn [ App\Providers\AppServiceProvider::class, App\Providers\AvsbServiceProvider::class,];On Laravel 10, add the same class to the providers array in config/app.php.
Avsbhq\Avsb\Middleware\Laravel\ServiceProvider performs the same container binding as the provider above, but it is deliberately framework-free: it does not extend Illuminate\Support\ServiceProvider, so the package never takes a hard dependency on Laravel. Laravel cannot boot a class from the providers array that is not a framework service provider, so list your own wrapper instead.
Register the route middleware. It reads the authenticated user id, falls back to the session id, promotes any avsb_* request parameters to targeting attributes, and sets the result on the request as avsb_context. On Laravel 11 and later:
// bootstrap/app.phpuse Illuminate\Foundation\Configuration\Middleware;->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'avsb' => \Avsbhq\Avsb\Middleware\Laravel\Middleware::class, ]);})On Laravel 10, add the same entry to $middlewareAliases in app/Http/Kernel.php:
protected $middlewareAliases = [ // ... existing aliases ... 'avsb' => \Avsbhq\Avsb\Middleware\Laravel\Middleware::class,];Then apply it to the routes that need flags:
Route::middleware('avsb')->get('/checkout', [CheckoutController::class, 'show']);Read a flag in a controller. AvsbServer comes from the container, and the context comes off the request:
<?phpnamespace App\Http\Controllers;use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\EvalContext;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;class CheckoutController extends Controller{ public function __construct(private readonly AvsbServer $avsb) {} public function show(Request $request): JsonResponse { // Set by the 'avsb' route middleware. Build one yourself on routes // that do not run it. $context = $request->attributes->get('avsb_context') ?? EvalContext::user((string) $request->user()?->getAuthIdentifier()); $checkout = $this->avsb->getBoolFlag('checkout_v2', false, $context); $theme = $this->avsb->getStringFlag('ui_theme', 'default', $context); return response()->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. Build one directly when you need attributes the middleware does not carry:
use Avsbhq\Avsb\EvalContext;$context = EvalContext::user((string) $user->id, [ 'plan' => $user->plan, // targeting attributes, any JSON value 'betaTester' => true,]);// Any single kind$org = EvalContext::user('acme-corp', ['tier' => 'enterprise'], 'organization');// Several kinds at once$context = EvalContext::multi([ 'user' => ['key' => (string) $user->id, 'plan' => $user->plan], 'organization' => ['key' => $user->org_key, '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 the served variation's key; null only for not_found and 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.
Optional: the Avsb facade
The package ships a facade for static access. It needs the instance handed to it once, which the boot() method of your provider is the right place for:
use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\Middleware\Laravel\Facade as AvsbFacade;public function boot(): void{ AvsbFacade::setInstance($this->app->make(AvsbServer::class));}Alias it in the aliases array of config/app.php:
'aliases' => [ // ... existing aliases ... 'Avsb' => Avsbhq\Avsb\Middleware\Laravel\Facade::class,],Calls forward to the same instance, so the context argument is still required:
$flag = Avsb::getBoolFlag('checkout_v2', false, $context);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. A request that reads no flags adds no shutdown work at all, so there is nothing to wire up.
$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 (Laravel Octane, a long-running queue worker) keeps the datafile it loaded at construction. Under Octane, bind with $this->app->scoped(...) instead of singleton(...) so each request gets a fresh instance, or call replaceDatafile() on a schedule you own.
Testing
Build a datafile in the test and construct a server from it, then swap it into the container. InMemoryDatafileLoader returns a server with events and heartbeats switched off, so a suite never touches the network by accident.
<?phpnamespace Tests\Feature;use Avsbhq\Avsb\AvsbServer;use Avsbhq\Avsb\TestUtils\InMemoryDatafileLoader;use Tests\TestCase;final class CheckoutTest extends TestCase{ private function serverWithCheckoutOn(): AvsbServer { return 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); } public function test_it_serves_the_new_checkout_when_the_rule_matches(): void { $this->app->instance(AvsbServer::class, $this->serverWithCheckoutOn()); $this->getJson('/checkout')->assertJson(['showNewCheckout' => true]); } public function test_it_serves_the_default_when_the_flag_is_unknown(): void { $server = $this->serverWithCheckoutOn(); $flag = $server->getBoolFlag('no_such_flag', false, \Avsbhq\Avsb\EvalContext::user('alice')); self::assertFalse($flag->value); self::assertSame('not_found', $flag->source->value); self::assertFalse($flag->exists()); }}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
- Symfony integration: the same package, wired through the Symfony 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.