Skip to main content

Documentation

No results found.
Features

Shared A/B Testing Engine

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 "r...

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:

  1. Every variant's markup renders into the cached page. The owning feature embeds each variant's identity (experiment key, variant key, full weighted variant list) in its client payload.
  2. The browser assigns stickily. abAssign(experiment, variants) in resources/js/ab-testing.js does a weighted random pick, persisted in localStorage (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.
  3. The browser reports. abExposure() / abConvert() send a navigator.sendBeacon POST (fetch-keepalive fallback) to /_ab/hit — each capped at once per session per experiment (sessionStorage wpcms_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

  1. 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? */);
    });
    
  2. Render all variants into the (cached) page, each carrying exp, var, and the full vars list ([{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.
  3. 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.
  4. Read results with AbStats::totals('myscope', [$experimentKeys]) and AbStats::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.