The problem#
Every WordPress site owner eventually hits the same wall: "Does the new hero convert better than the old one?" "Is the green CTA winning or losing against the red one?"
The honest tools to answer that question — Optimizely, VWO, Google Optimize (RIP) — assume you can drop arbitrary JavaScript on the page, mutate the DOM, and rely on cookies to keep a visitor on the same variant. On WordPress with full-page caching turned on, those assumptions break:
- Cookies and sessions are stripped by the cache layer before they reach PHP.
- DOM mutation happens after first paint, which causes flicker (FOOC) and inflates CLS — a measurable hit to Core Web Vitals.
- Editors lose visibility. The marketing person who wants to test a hero variation can't do it from inside the block editor; they have to ticket the dev team and wait.
The plugin solves that specific shape of the problem: make A/B testing a first-class block in Gutenberg, with deterministic variant assignment that survives full-page caching, no external JS, and built-in tracking that doesn't require a tag manager.
How it works#
Once activated, the plugin adds an A/B Test block to the Gutenberg inserter. The editor flow looks like this:
- Insert the A/B Test parent block on any post or page.
- Two child A/B Variant blocks are auto-created (
Variant A,Variant B). - Each variant accepts any nested blocks — headings, images, columns, WooCommerce product grids.
- In the block sidebar, set the Click selector (CSS selector for conversion events) and Section selector (for dwell tracking).
- Save the post.
At runtime, each visitor sees exactly one variant. The plugin records three event types:
| Event | When it fires | What it's used for |
|---|---|---|
impression |
Once per visitor per block, deduplicated by session hash | "How many unique visitors saw this variant?" |
click |
On any click matching the configured CSS selector inside the visible variant | CTR — the canonical conversion signal |
dwell |
After at least 500 ms of cumulative visible time on the section | Engagement quality |
The A/B Testing → Results dashboard in wp-admin then aggregates per variant: impressions, clicks, CTR, average dwell, sample size.
Architecture decisions#
1. Round-robin variant assignment instead of sessions#
The single most important design decision. Cookies and WP_Session are both incompatible with object-caching and CDN-level full-page caching — both would either skip the cache for every visitor or pin everyone to the first variant rendered.
Instead, the variant index is derived from total impressions:
public function resolveVariant(string $blockId, int $variants, int $groupSize): int
{
$count = (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->events}
WHERE block_id = %s AND event_type = 'impression'",
$blockId
)
);
$cycle = $variants * $groupSize;
return (int) floor(($count % $cycle) / $groupSize);
}With variants = 2 and groupSize = 5, visitors 1–5 see Variant A, 6–10 see Variant B, 11–15 Variant A again, and so on. Deterministic, atomic, cache-friendly.
The groupSize knob exists because pure single-shot round-robin (group size 1) produces a flicker on shared screens — group size 5 is the empirical sweet spot for "looks like the same site to multiple visitors arriving close together."
2. Impression dedup without storing PII#
To stop the same visitor from counting twice, every impression is keyed by a deterministic session hash — but no identifying info is ever persisted:
private function makeSessionHash(string $blockId, \WP_REST_Request $request): string
{
$ip = $request->get_header('x-forwarded-for') ?? ($_SERVER['REMOTE_ADDR'] ?? '');
$ua = $request->get_header('user-agent') ?? '';
return hash('sha256', $blockId . $ip . $ua . (wp_salt()));
}The salted SHA-256 is what lands in the session_hash column. The IP and user agent never touch the database. This is GDPR-friendly out of the box — there's no personal data to delete, because there's no personal data to begin with.
3. Dynamic parent block, static variant blocks#
The parent block (ab-testing-gutenberg/ab-test) uses render_callback, so the variant index attribute can be injected server-side, after wp_kses_post would otherwise strip it. The variant child blocks (ab-testing-gutenberg/variant) save flat HTML — <div class="abtg-variant">…</div> — and the parent's renderer walks the saved markup and tags each variant:
$variantIndex = 0;
$processed = preg_replace_callback(
'/<div\b([^>]*\bclass="[^"]*\babtg-variant\b[^"]*"[^>]*)>/',
function (array $m) use (&$variantIndex): string {
return '<div' . $m[1] . ' data-variant-index="' . ($variantIndex++) . '">';
},
$content
);
$variants = max(1, $variantIndex);This sidesteps two problems at once: wp_kses_post keeps stripping unknown data-* attributes from saved markup, and visitors who block JS still get a sane fallback (CSS hides all variants until tracker.js reveals one).
4. Two custom tables, never touch wp_posts#
Event volume on a busy site can easily hit five-figure rows per day. The plugin uses two purpose-built tables — wp_abtg_tests (one row per test) and wp_abtg_events (one row per impression / click / dwell). The wp_postmeta table is left untouched, which keeps core query performance unaffected and makes uninstall a clean DROP TABLE.
5. Dwell tracking that survives tab close#
Browsers don't reliably fire unload anymore. The tracker uses IntersectionObserver to measure visible time, then flushes the accumulator on visibilitychange and pagehide with fetch(..., { keepalive: true }) — the only combination that is guaranteed to deliver a payload after the page is being torn down.
Install#
- Download the signed zip from the link at the top of this page.
- In
wp-admin → Plugins → Add New → Upload Plugin, choose the zip and click Install Now. - Activate. Two database tables are created on activation (
{prefix}abtg_tests,{prefix}abtg_events). - Open any post or page in Gutenberg, search the inserter for A/B Test, drop the block in.
- Configure your Click selector (e.g.
.btn-buy) in the block sidebar. - Publish. Results appear under A/B Testing → Results in the admin sidebar.
Once installed, the plugin self-checks for updates from a dedicated update server — the same wp-admin → Plugins UI you already know.
Stack#
- WordPress 6.7+, PHP 8.1+,
declare(strict_types=1)everywhere register_block_type(dynamic parent + static children),render_callbackinjection- REST API:
POST /wp-json/abtg/v1/event,GET /wp-json/abtg/v1/variant - Two custom tables created via
dbDeltaon activation - Vanilla JS tracker (no jQuery, no React on the front end)
IntersectionObserver+keepalivefetch for dwell deliveryyahnis-elsts/plugin-update-checkerv5 for self-hosted updates