Core (free) infrastructure for split-testing anything that renders into response-cached public HTML. First consumer: Popups & Announcement Bars variants; designed to later serve page/row variants and any other feature that wants "render every version, let the browser pick one."
The core idea: cache both variants, assign client-side
ResponseCache stores one HTML body per URL for every visitor, so the server can never choose a variant per visitor. Instead:
- Every variant's markup renders into the cached page. The owning feature embeds each variant's identity (
experimentkey,variantkey, full weighted variant list) in its client payload. - The browser assigns stickily.
abAssign(experiment, variants)in resources/js/ab-testing.js does a weighted random pick, persisted inlocalStorage(wpcms_ab, a{experiment: variant}map), so the same visitor always sees the same variant. A stored assignment naming a variant that no longer exists is re-picked; single-variant lists resolve without persisting. - The browser reports.
abExposure()/abConvert()send anavigator.sendBeaconPOST (fetch-keepalive fallback) to/_ab/hit— each capped at once per session per experiment (sessionStoragewpcms_ab_sent) so rates stay per-visitor honest.
Server side
| Piece | File | Role |
|---|---|---|
| Rollup table | ab_daily_stats migration |
One row per scope+experiment+variant+date: exposures, conversions. Aggregate counters only — no visitor identifiers, mirroring page_engagement's privacy posture. |
| Recorder / reader | app/Support/AbTesting/AbStats.php | Atomic upsert increments (MySQL/MariaDB + ON CONFLICT branches); totals() per experiment/variant; confidence() — one-sided two-proportion z-test, null until each side has ≥30 exposures and ≥5 combined conversions. |
| Scope registry | app/Support/AbTesting/AbRegistry.php | Singleton (AppServiceProvider). Consumers register a scope + validator closure from their provider via afterResolving — boot stays DB-free. |
| Beacon | app/Http/Controllers/AbHitController.php, POST /_ab/hit in routes/cms.php |
Mirrors the Analytics engagement beacon: CSRF-exempt (bootstrap/app.php), always answers 204 (never an oracle), skips DNT/Sec-GPC and manager+ staff, in-controller rate limit (60/min/IP). |
Junk can't mint rows. A hit only records when its scope is registered and the scope's validator confirms the (experiment, variant) pair names something real — e.g. the Popups validator checks an enabled popup_items row with that experiment key and variant (or, for standalone item-{id} keys, that the item exists outside any experiment). Validation results are cached for 5 minutes, so the per-pageview beacon costs ~zero queries.
Payload/beacon contract
POST /_ab/hit {"s": scope, "e": experiment, "v": variant, "k": "e"|"c"}
s≤32 chars,e≤64,v≤32, body ≤512 bytes.k:e= exposure (the visitor actually saw the variant — not merely "it was on the page"),c= conversion (the feature defines what converts: Popups counts CTA clicks and signup submits).
Adding a new consumer
- Register a scope in your service provider:
$this->app->afterResolving(AbRegistry::class, function (AbRegistry $registry): void { $registry->register('myscope', fn (string $experiment, string $variant): bool => /* exists? */); }); - Render all variants into the (cached) page, each carrying
exp,var, and the fullvarslist ([{v, w}, ...]) in its payload. Ship the full sibling list even for variants a filter keeps off the current page — assignment must see complete weights to stay sticky everywhere. - In your JS:
abAssign(exp, vars)→ show only the chosen variant;abExposure('myscope', exp, var)when it's actually seen;abConvert(...)on your conversion action. - Read results with
AbStats::totals('myscope', [$experimentKeys])andAbStats::confidence($a, $b).
Pre-paint consumers: if a variant server-renders visible (like immediate announcement bars), duplicate the tiny assignment algorithm in an inline head script — same wpcms_ab storage key, same weighted pick — and hide the losers before first paint. See popups::public.head for the reference implementation; keep the two algorithms in sync.
What it deliberately is not
- Not per-visitor server logic — no cookies read server-side, no cache variance, no session storage.
- Not an email A/B system — Marketing's subject-line test is a separate phased state machine in
SendCampaignJob(send-time decisions, not cached-HTML display decisions). - Not an analytics suite — counters only. Attribution, funnels, and visitor-level analysis stay in the Analytics feature.