Skip to content

split url testing

Convert Bot edited this page Aug 20, 2026 · 1 revision

Split URL Tests with the SDK

In a web project, Split URL is a dedicated experience type: you give Convert two or more URLs and the tracking script redirects the browser to the one the visitor was bucketed into.

Fullstack projects have no split_url experience type — see Requirements. You get the same test by running a standard a/b_fullstack experience and doing the routing yourself, in the code that handles the request. The SDK returns a variation; which page that variation serves is your decision.

This page covers what changes when you move the split server-side, how to express URL targeting under Fullstack's rule types, where to put the routing so both arms are counted symmetrically, and the specific ways a server-side split can still produce a Sample Ratio Mismatch.

This is a rebuild, not a migration. A Fullstack experience lives in a Fullstack project with its own entity keys, its own audiences and its own goals. There is no import path from a web Split URL experience and no report continuity — the new experience starts from zero. Plan it as a new test.

What Changes

Web Split URL Fullstack equivalent
Experience type split_url a/b_fullstack
Who sends the visitor to the variant Convert's tracking script, in the browser your request handler, before the response is built
What a variation is a destination URL a variation key you map to a route, template or origin path
URL targeting URL rules configured in the UI a generic_text_key_value rule matched against a key you pass
When the visitor is counted after the browser reaches the destination at the runExperience call, before the response is sent
Conversions auto-detected by the tracking script you call trackConversion — see Tracking Conversions

The counting row is the one that changes your numbers. Server-side, a visitor is counted the moment you bucket them, so someone who abandons before the page finishes loading is still in the report. Totals read slightly higher than the equivalent web Split URL test. That is expected, and because it applies to every arm equally it does not skew the split.

Configure the Experience

Create an a/b_fullstack experience with one variation per destination. Every entity needs a unique key — the variation keys are what your routing code switches on, so name them for the destination (checkout-v2, pricing-simple) rather than variation-1.

Fullstack rules are limited to four types. URL targeting is expressed with generic_text_key_value, matched against a key you pass in locationProperties:

What you want to target Rule type and matching Key to pass
Everything under a path generic_text_key_value + startsWith path/checkout
A URL containing a fragment generic_text_key_value + contains url
An exact page generic_text_key_value + matches path or url
A pattern across many pages generic_text_key_value + regexMatches path or url
A file extension or suffix generic_text_key_value + endsWith path
One hostname of several generic_text_key_value + matches hostname

The key name in the rule and the key you pass must be identical — the SDK matches them verbatim and does no normalizing. If your web rules were written against the full URL, pass the full URL; if they were written against the path, pass the path. The complete key-by-key mapping for audiences, locations and goals is in From the Tracking Script to the SDK.

Audiences in a Fullstack project should be transient — conditions are re-checked every time the visitor encounters the experience. Sticky bucketing across sessions comes from a Persistent DataStore, not from the audience.

Route in the Request Handler

Bucket once, at the top of the handler, then branch on the variation key. Both arms must come out of the same runExperience call.

use OpenAPI\Client\BucketingAttributes;

Route::get('/checkout/{any?}', function (Request $request) {
    // Both come from your visitor-id middleware — a closure captures nothing it
    // does not list in use(...), so resolve them from the request.
    $context   = $request->attributes->get('convertContext');
    $visitorId = $request->attributes->get('convertVid');

    $variation = $context?->runExperience('checkout-split', new BucketingAttributes([
        'locationProperties' => [
            'url'      => $request->fullUrl(),
            // path() strips the leading slash — restore it so the value matches
            // a rule configured as `/checkout`.
            'path'     => '/' . ltrim($request->path(), '/'),
            'hostname' => $request->getHost(),
        ],
    ]));

    $key = $variation?->variationKey;
    $template = $key === 'checkout-v2' ? 'checkout-v2' : 'checkout';

    return view($template, ['visitorId' => $visitorId]);
})->where('any', '.*');

Two shapes, and which to prefer

Serve different content under one URL. The visitor stays on /checkout and your handler decides what to render or which origin to fetch. Prefer this. The browser URL never changes, so there is no second URL for anyone to reach directly, no second cache key, and no navigation step between the decision and the response.

Redirect to a genuinely different URL. Only do this when the URL itself is part of what you are testing — a separate landing page, a different domain, a page you need indexed separately. Bucket and count before you issue the redirect, so the visitor is recorded whether or not they arrive, and read the direct-entry hazard below.

Keep Both Arms Symmetric

Moving the split server-side removes the browser from the counting path. A redirect the browser blocks, a visitor who leaves mid-navigation, an extension or privacy setting that stops the hop — none of those can cost you an exposure any more, because the exposure is recorded on the server before the response is built.

It does not make Sample Ratio Mismatch impossible. Convert's SRM check reads the exposure counts per variation; it does not know or care where the bucketing happened, so a Fullstack experience is checked the same way a web one is. Note that the check is off by default — turn it on in the project's statistics settings, or nothing will alert you to an asymmetric split. What changes is the list of causes. These are the ones that belong to you now:

  1. Bucket before you branch. One runExperience call, above every conditional. The moment one arm reaches a code path that buckets and another doesn't — an early return, a guard clause, a cached fast path, an error handler that renders the control — that arm stops being counted. This is the most common cause and the easiest to introduce during a refactor.

  2. Flush before the response ends. Short-lived runtimes (serverless functions, edge workers, CLI jobs) can finish before the SDK's batch timer fires, dropping whatever is still queued. Call releaseQueues / flush on every path that returns a response, not just the happy one. If one arm returns early and skips the flush, that arm under-counts and the other does not.

  3. Cache or CDN in front of one arm. A cached response means your handler never runs, so nothing is bucketed and nothing is counted — while the uncached arm keeps counting normally. If a bucketed route sits behind a cache, either vary the cache key on your visitor-ID cookie or bypass the cache for that route.

  4. Direct entry to one arm's URL. Only applies if you used genuinely distinct URLs. Search results, bookmarks, ads, email links and internal links send people straight to one arm without passing through the split, and that traffic is counted for that arm only. Either route transparently under a single URL, or make the destination handler run the same runExperience call so entry from any direction is bucketed identically.

  5. Visitor-ID scope. You own the ID. If the cookie is set with a path or domain narrower than every URL in the test, one arm sees returning visitors as new ones and the split drifts. Set it once, at the outermost path and domain that cover all arms, and read it everywhere — including on any conversion fired later. See Server-Side Experimentation for the identity patterns.

  6. Bots and crawlers. Convert's browser tracking screens known bots and crawlers out before they are counted. Events arriving from an SDK are trusted as coming from your own server, so that screening does not apply to them — every crawler that reaches your handler gets bucketed and counted. Crawlers do not distribute themselves evenly across arms, so this shows up as skew. Filter obvious non-human traffic (User-Agent, your CDN's bot score) before you call runExperience, or pass the SDK's tracking-disable option for those requests so they bucket without emitting an event.

A useful check before you launch: run the handler against both arms with tracking on and confirm you see exactly one exposure event per request on each. If one arm is silent, you have found the SRM before your visitors did.

Track Conversions

Conversions work the same as in any Fullstack experience — call trackConversion with the goal key from wherever the conversion actually happens, using the same visitor ID that was bucketed. If the conversion happens on a later request, or on a page the split routed to, the visitor ID cookie is what links it back. Goal-by-goal recipes are in Tracking Conversions and From the Tracking Script to the SDK.

See Also

Clone this wiki locally