amjadiqbal/laravel-pennant-experiments
A Laravel package for managing feature flag experiments with Pennant, helping you define, run, and track A/B tests and rollouts through a clean, expressive API.
composer require <vendor>/laravel-pennant-experiments
php artisan vendor:publish # config + migrations
php artisan migrate
php artisan about # confirms the "Experiments" entry is wired up
First three things to look at, in order:
viewPennantExperiments and managePennantExperiments are local-only by default. Define them in a service provider before you try the results UI anywhere but local, or you will hit 403s and assume the package is broken.php artisan pennant-experiments:results is the fastest way to confirm definitions are registered and exposures are flowing.First use case — a two-variant test on a checkout CTA, defined once in a dedicated provider:
// app/Providers/ExperimentServiceProvider.php (boot())
Experiments::define('checkout-cta')
->variants(['control' => 50, 'urgent' => 50]) // first variant is the control
->goals(['purchase']);
Render it, and remember that resolution and exposure are two separate calls:
$variant = Feature::value('checkout-cta'); // assigns (sticky, hashed) — does NOT count
Experiments::expose('checkout-cta'); // records the exposure
In Blade, use @variant (confirm the exact signature in the README) — it records the exposure for you, which is the whole point of using it over Feature::value().
Expose at the render boundary, not in middleware. Exposures should equal "this participant actually saw a variant." Calling experiments()->expose() from a controller that might redirect, or from a base layout shared by 30 pages, inflates the denominator and quietly biases every result. Put @variant around the markup that changes.
Conversions: PHP for domain events, JS for UI intent. Server-side Experiments::convert('checkout-cta', 'purchase') from a listener on your OrderPaid event is the trustworthy path. For things that only exist in the browser (video play, scroll depth, modal open), include @experimentsScript once per layout and either call PennantExperiments.convert(...) or drop data-experiment-convert on the element — the latter is the least invasive option for existing marketing markup.
Let the Login handoff do its job. Guest → user migration is automatic on Login: exposures, conversions and the variant move across, count once, and keep the same variant. The practical rule is don't re-expose after login and don't convert twice in a sign-up flow — the package already deduplicates per scope and goal.
Set the planned sample size before launch. Either supply it directly or set a minimum detectable effect and let the package derive it. This is what makes "Keep running" meaningful. Without it you get the minimum-data rule and an honest shrug; with it, the verdict vocabulary (Better / Worse / No significant difference) actually fires.
Plan the run, then end it in a controlled way. pennant-experiments:end with winner rollout rewrites stored Pennant values so every participant — new and prior — gets the winner, and recording stops. Treat that as a deploy-time action, not a button-press: it changes what production code reads from Feature::value(). "Run again" starts a clean run, which is the correct move when you want to change weights (see gotchas).
Lock the UI down per environment. Read-only mode on in production, managePennantExperiments restricted to an admin list, viewPennantExperiments for whoever reads results. Read-only gives you the dashboard without the ability to end a live test from a browser tab.
Keep eligibility rules narrow and explicit. Excluding staff, internal IPs, and pre-launch accounts at definition time is much cheaper than trying to filter them out of the numbers later — you can't retroactively un-expose someone.
Dashboard integration is optional and decoupled. With amjadiqbal/laravel-pennant-dashboard v1.1+ you get an Experiments tab; on older versions you get a link back. Neither package requires the other, so version them independently.
Test the assignment logic, not the statistics. In Pest/PHPUnit, assert that a given scope resolves to the variant you expect, and flush Pennant's feature cache between tests — stickiness is the feature, and it will leak across test cases otherwise.
variant() never counts. Resolving the feature value without expose() (or @variant) produces a results page with traffic but zero exposures. If your N is 0 while conversions exist, this is the bug — conversions are gated on exposure.php artisan about is your cheap smoke test. If the Experiments entry is missing, the provider isn't registered and the artisan commands won't behave as expected.How can I help you explore Laravel packages today?