Stable bucketing, one cookie and tracking you can trust.

Everyone loves telling you to “run an A/B test” right up until you ask what that means in code. Then the advice gets fuzzy fast: use a feature flag tool, add analytics, track a funnel, be statistically responsible. All true, none especially useful when you are staring at a Laravel app and just want to test whether headline B beats headline A without turning the project into its own startup.
The easiest reliable implementation is much smaller than people make it sound. You need four things:
- one stable visitor ID
- one deterministic way to assign a variant
- one place to read that variant while rendering the page
- one write path for impressions and conversions
That is enough to ship a real experiment. Not a toy, not a slide deck experiment, but one you can put in production this week.
The important constraint is stability. If the same visitor sees A on Monday and B on Tuesday because your assignment logic changed or the cookie disappeared, your results are junk before you even get to the dashboard. Most broken A/B test implementations do not fail because of advanced statistics. They fail because the assignment layer is sloppy.
What the Smallest Useful A/B Test Must Guarantee
Before touching Laravel, define the contract. A small implementation still has to protect you from obvious lies.
Your experiment layer must guarantee:
- the same visitor stays in the same variant for the life of the test
- assignment does not require a database read on every page view
- impressions and conversions are recorded in a way you can deduplicate
- turning the experiment off is just a config change, not a migration project
That is the bar. If your setup cannot do those four things, you did not build A/B testing. You built a coin flip with charts.
Notice what is not on that list:
- a vendor dashboard
- a visual editor
- multi-armed bandits
- auto-rollout logic
- Bayesian anything
Those may matter later. Right now they are how teams delay a simple decision for three weeks.
Start With the Experiment Definition, Not the UI
The easiest place to define experiments in Laravel is a config file. That sounds almost boring enough to be wrong. It is not.
You want the experiment definition to live in code because the first version usually changes quickly. Product wants different copy. Engineering wants a kill switch. Someone realises the variant only makes sense for signed-in users. A plain config file is fast to review, easy to deploy, and hard to misunderstand.
Here is a small shape that holds up:
<?php
return [
'pricing_cta' => [
'enabled' => true,
'variants' => [
'control' => 50,
'trial_first' => 50,
],
'metric' => 'started_checkout',
],
];This does three useful things.
- The experiment key is explicit.
- Variant weights are obvious.
- The metric you care about is named before anyone starts reading charts creatively.
That third point matters more than people admit. If you do not name the primary metric before launch, somebody will discover after the fact that variant B increased clicks, variant A increased purchases, and both sides can now claim victory. Very efficient way to waste an afternoon.
Use a Stable Visitor ID and Deterministic Bucketing
This is the heart of the implementation, not the dashboard, not the admin panel, but the assignment rule itself.
Do not generate a random variant on each request. Do not store “current variant” in the session and hope that is enough. Do not recalculate from unstable inputs like IP address. Laravel’s own request docs explicitly treat IP addresses as untrusted, user-controlled input. That alone should keep them out of your bucketing logic.
Use one cookie-backed visitor ID, then map that visitor deterministically to a variant using a hash. Same visitor ID plus same experiment key should always produce the same answer.
That gives you stability without a database lookup. Cheap and predictable. Good combination.
The Small Service That Does the Real Work
You do not need a package first. You need one class that knows how to answer one question: for experiment X, which variant does this visitor belong to?
<?php
namespace App\Support;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;
class ExperimentManager
{
public function __construct(private Request $request)
{
}
public function variant(string $key): ?string
{
$definition = config("experiments.$key");
if (! $definition) {
return null;
}
if (! ($definition['enabled'] ?? false)) {
return null;
}
$visitorId = $this->visitorId();
$bucket = $this->bucketFor($key, $visitorId);
$runningTotal = 0;
foreach ($definition['variants'] as $variant => $percentage) {
$runningTotal += $percentage;
if ($bucket < $runningTotal) {
return $variant;
}
}
return array_key_first($definition['variants']);
}
private function visitorId(): string
{
$visitorId = $this->request->cookie('ab_id');
if ($visitorId) {
return $visitorId;
}
$visitorId = (string) Str::uuid();
Cookie::queue('ab_id', $visitorId, 60 * 24 * 365);
return $visitorId;
}
private function bucketFor(string $experimentKey, string $visitorId): int
{
$hash = hash('sha256', $experimentKey.':'.$visitorId);
return hexdec(substr($hash, 0, 8)) % 100;
}
}There is nothing glamorous here, which is exactly why it works.
Laravel’s request docs give you $request->cookie(…). Laravel’s response docs give you Cookie::queue(…) so the visitor ID cookie gets attached before the response leaves the app. The rest is plain PHP.
A few reasons this shape is worth keeping:
- a visitor stays stable across requests
- changing the page template does not change assignment
- weights live in config, not in scattered conditionals
- you can swap the cookie ID for a user ID later when a visitor logs in
That last point is important. Start anonymous if you need to, then promote the bucketing key to user_id later if your product has strong authenticated flows. The pattern stays the same.
Reading the Variant in a Controller or Blade View
Once the manager exists, using it should be boring.
In a controller:
<?php
use App\Support\ExperimentManager;
class PricingController
{
public function show(ExperimentManager $experiments)
{
return view('pricing.show', [
'ctaVariant' => $experiments->variant('pricing_cta'),
]);
}
}Then in Blade:
@if ($ctaVariant === 'trial_first')
<a href="{{ route('signup') }}" class="btn btn-primary">
Start your free trial
</a>
@else
<a href="{{ route('signup') }}" class="btn btn-primary">
Create your account
</a>
@endifThat is enough to render different experiences. No SPA rewrite, no client-side assignment race, no analytics SDK deciding after the page already painted.
This is one of the reasons I prefer server-side assignment for the easiest implementation. The variant is decided before the view renders, so you do not get the ugly flicker where a user briefly sees A before some client-side script swaps in B. That flicker looks sloppy to users and messy in your data.
Middleware Is Useful When Many Pages Need the Same Experiment
If several routes need the same experiment variant, route middleware can keep controller code clean. Laravel 13 still registers middleware aliases in bootstrap/app.php, so the setup is straightforward.
Alias the middleware:
<?php
use App\Http\Middleware\AssignPricingExperiment;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'experiment.pricing' => AssignPricingExperiment::class,
]);
})Then the middleware can share the chosen variant with the request or the view:
<?php
namespace App\Http\Middleware;
use App\Support\ExperimentManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AssignPricingExperiment
{
public function handle(Request $request, Closure $next, ExperimentManager $experiments): Response
{
$request->attributes->set(
'pricing_cta_variant',
$experiments->variant('pricing_cta')
);
return $next($request);
}
}I would not start here unless multiple routes need it immediately. The controller version is simpler. Middleware is what you add when you notice repetition, not what you add because it sounds more architectural.
That distinction matters. “Easy to explain in a meeting” and “easy to maintain in the repo” are not the same thing.
Track Impressions and Conversions Like They Might Be Sent Twice
This is where many homegrown A/B test setups quietly become fiction. They assign variants correctly, then write tracking logic as if browsers, queues and users are perfectly polite.
They are not, and the failure modes are painfully ordinary: someone refreshes, the frontend retries, a checkout callback lands twice, or a button double-submits. If your event writes are not idempotent enough for the level of certainty you need, the numbers get noisy fast.
The easiest sane version is to store events with a key that lets you deduplicate. You do not need a giant analytics schema. You need one table that can answer:
- which experiment was active
- which metric this event belongs to
- which visitor produced it
- which variant they saw
- whether this row is an impression or a conversion
Then write through one path.
Laravel’s query builder gives you updateOrInsert, which is useful here because repeated writes can update the same logical event instead of creating fresh duplicates.
<?php
use Illuminate\Support\Facades\DB;
final class ExperimentTracker
{
public function recordConversion(
string $experimentKey,
string $metricKey,
string $visitorId,
string $variant
): void {
DB::table('experiment_events')->updateOrInsert(
[
'experiment_key' => $experimentKey,
'metric_key' => $metricKey,
'visitor_id' => $visitorId,
'event_type' => 'conversion',
],
[
'variant' => $variant,
'recorded_at' => now(),
]
);
}
}This is not magic. It is just honest about retries.
For impressions, you can use the same pattern or choose a lighter rule such as “one impression per visitor per experiment per day” depending on what you are measuring. The exact event model is your product decision. The reliability rule stays the same: assume duplicate attempts will happen, then write code that behaves well when they do.
The Simplest Possible Request Flow
If you want the whole thing in one glance, this is the flow I would recommend for a first implementation:
- User hits the pricing page.
- ExperimentManager reads or creates ab_id.
- Manager deterministically assigns pricing_cta.
- Controller renders the Blade view with that variant.
- Impression is recorded once.
- If the user starts checkout, conversion is recorded through one server-side write path.
That is enough for a real experiment.
You do not need to solve every future problem before the first test goes live. You need a flow where the same person reliably sees the same version and the result write is not fragile. Start there.
The Test That Keeps You Honest
If you are going to ship experimentation code, test the invariant that actually matters: the same visitor cookie gets the same variant.
Laravel’s HTTP test tools already support withCookie(…), which makes this easy to prove.
<?php
test('the same visitor stays in the same pricing variant', function () {
$first = $this->withCookie('ab_id', 'visitor-123')->get('/pricing');
$second = $this->withCookie('ab_id', 'visitor-123')->get('/pricing');
$first->assertOk();
$second->assertOk();
$first->assertSee('Start your free trial');
$second->assertSee('Start your free trial');
});That is not the only test you need, but it is the first one I would write. If this fails, your experiment data is already drifting.
I would add two more quickly after that:
- a test proving a missing ab_id cookie causes Laravel to queue one
- a test proving conversion writes do not create duplicate rows for the same visitor and metric
The test suite is where a lot of fake experimentation maturity gets exposed. Teams love talking about significance and lift. Fewer teams prove that a visitor does not silently bounce between A and B because somebody refactored the assignment code on Friday.
What This Small Setup Handles Well
This version of A/B testing is enough when:
- you are testing one page or one flow at a time
- the variants are rendered by Laravel
- your primary conversion happens on your own server
- you care more about shipping a clean test than buying a new tool
It is especially good for:
- headline tests
- pricing page CTA tests
- onboarding copy tests
- checkout step-order tests
In other words, the kind of experiments most small teams actually run.
What It Does Not Handle Yet
This setup will start to feel thin when:
- multiple services need to evaluate the same experiment
- mobile apps and the web app must share one assignment source
- product wants non-technical staff changing weights every hour
- you need holdouts, audience targeting, or cross-device identity stitching
- analytics must be near real-time and self-serve
That is when a dedicated experimentation platform starts earning its keep.
But this is where teams often get the sequence backward. They start by shopping for the mature platform before they have even run two decent experiments. Much better to earn the complexity. Build the simple version, learn what your team actually uses, then outgrow it on purpose.
The Mistake I Would Avoid First
Do not tie A/B testing to a database lookup on every request unless you genuinely need runtime rule changes that cannot wait for deployment.
People reach for the database because it feels flexible. It is flexible. It also turns a simple render decision into a runtime dependency that now has to be fast, cached, observable and safe under failure. For the first experiment, config plus deterministic bucketing is usually the better trade.
That is the real theme here. The easiest implementation is not the one with the fewest files. It is the one with the fewest moving parts that can still tell the truth.
If your A/B testing setup can keep a visitor stable, render the right variant, and write one trustworthy conversion row, you have enough to start learning something useful.


