rasuvaeff / yii3-ab-testing-web
Cookie identity and sticky-variant store for Yii3 A/B testing
Requires
- php: 8.3 - 8.5
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- rasuvaeff/yii3-ab-testing: ^1.2
- yiisoft/cookies: ^1.2
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- nyholm/psr7: ^1.8
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-07-25 11:34:10 UTC
README
Web identity and sticky-variant layer for Yii3 A/B testing. Gives every visitor a stable subject id (so deterministic assignment holds across visits) and, when you need it, pins a subject to a variant across weight changes via a signed cookie.
Using an AI coding assistant? llms.txt contains a compact API reference you can ingest in your prompt context.
Requirements
- PHP 8.3+
rasuvaeff/yii3-ab-testing^1.2 (addsAssignmentStoreandAssignment::isSticky)yiisoft/cookies^1.2- a PSR-7 implementation (e.g.
nyholm/psr7) and a PSR-15 stack
Installation
composer require rasuvaeff/yii3-ab-testing-web
Identity vs stickiness
Assignment is deterministic in subjectId (sha256(salt:subjectId)), so a stable
id alone keeps a visitor in the same variant across visits — no variant is stored.
Two cookie roles solve two different problems:
| Need | Use |
|---|---|
| A stable id for anonymous visitors | SubjectIdMiddleware (cookie ab_id) |
| Keep a variant even after weights/variants change | CookieAssignmentStore + StickyAssignmentResolver |
A logged-in user already has a stable id (userId) — set it as the request
attribute upstream and the middleware leaves it alone.
Subject identity middleware
Add SubjectIdMiddleware to your PSR-15 stack. It resolves the subject id and
exposes it as a request attribute (ab.subjectId by default):
- if the attribute is already set (an upstream auth middleware put
userIdthere) it is kept — no cookie; - otherwise the
ab_idcookie is reused — only when theSubjectIdGeneratorInterfacerecognises the value as its own (32 lowercase hex chars by default); a tampered or oversized value is discarded and regenerated; - otherwise a new opaque id is generated and a long-lived
HttpOnly,SameSite=Laxcookie is set.
use Rasuvaeff\Yii3AbTestingWeb\SubjectIdMiddleware; $middleware = new SubjectIdMiddleware(); // defaults: cookie 'ab_id', attribute 'ab.subjectId' // in your action/handler: $subjectId = $request->getAttribute('ab.subjectId'); $assignment = $ab->assign(experiment: 'checkout-button', subjectId: $subjectId);
For most experiments this is all you need.
Custom subject id format
The id format and the check that accepts it back from the cookie are one
contract, SubjectIdGeneratorInterface. The default HexSubjectIdGenerator
produces 32 lowercase hex characters and accepts nothing else:
use Rasuvaeff\Yii3AbTestingWeb\SubjectIdGeneratorInterface; final readonly class PrefixedSubjectIdGenerator implements SubjectIdGeneratorInterface { public function generate(): string { return 'sub_' . bin2hex(random_bytes(8)); } public function isValid(string $id): bool { return preg_match('/^sub_[0-9a-f]{16}\z/', $id) === 1; } } $middleware = new SubjectIdMiddleware(idGenerator: new PrefixedSubjectIdGenerator());
Implement both halves or the middleware rejects its own cookie on the next request, mints a fresh id every time and — assignment being deterministic in the subject id — flips the visitor between variants on every page view.
isValid() is a security boundary, not a formality: the cookie is
attacker-controlled and whatever passes becomes the subject id in your logs and
analytics. Anchor the pattern with \z, not $ — PCRE's $ also matches
before a trailing newline.
To tie the id to the logged-in user, no generator is needed: an upstream
middleware that sets the ab.subjectId attribute wins over both cookie and
generator (rule 1 above), so the same person keeps one variant across devices.
Sticky variants
Changing weights or the variant set shifts bucket boundaries and reshuffles
subjects. To pin a subject across such changes, resolve through a
CookieAssignmentStore (a signed {experiment: variant} cookie) and a
StickyAssignmentResolver. Because the store is request-scoped, wire it in a thin
middleware that reads the cookie, exposes the store, and writes it back:
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Rasuvaeff\Yii3AbTestingWeb\CookieAssignmentStore; use Yiisoft\Cookies\CookieSigner; final class StickyCookieMiddleware implements MiddlewareInterface { public function __construct(private CookieSigner $signer) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $store = CookieAssignmentStore::fromRequest($request, $this->signer); $response = $handler->handle($request->withAttribute('ab.store', $store)); return $store->applyToResponse($response); } }
Then in your action:
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentResolver; $store = $request->getAttribute('ab.store'); // CookieAssignmentStore $resolver = new StickyAssignmentResolver($ab, $store); $assignment = $resolver->resolve( experiment: 'checkout-button', subjectId: $request->getAttribute('ab.subjectId'), ); // first time: assigned and stored; later: the stored variant is returned
StickyAssignmentResolver keeps AbTesting::assign() pure: a forced variant
bypasses the store, a disabled experiment returns its fallback (the kill switch
always wins and nothing is stored), and a stored variant that is no longer part of
the experiment is re-assigned.
API reference
| Class | Description |
|---|---|
SubjectIdMiddleware |
PSR-15 middleware; stable subject id + ab_id cookie |
SubjectIdGeneratorInterface |
generate() + isValid(): the id format and the check that accepts it back |
HexSubjectIdGenerator |
default: 32 lowercase hex characters |
CookieAssignmentStore |
AssignmentStore over one signed cookie; fromRequest() / applyToResponse() |
StickyAssignmentResolver |
get-or-assign over AbTesting + any AssignmentStore |
Security & privacy
- The subject id is an opaque 128-bit token (
random_bytes), not a UUID, and carries no personal data — but it is a persistent identifier. Set the cookie only after consent where the law requires it. - The sticky cookie is signed (
yiisoft/cookiesCookieSigner); a missing, unsigned, tampered, or malformed cookie is ignored and yields an empty store — never a partial or attacker-controlled variant map. Provide a strong signing key. - Cookies are
HttpOnly,SameSite=Lax, andSecureby default. - The cookie is browser-scoped: the
$subjectIdargument of the store is ignored. A visitor who was anonymous then logged in keeps the variants from their anonymous identity.
Examples
See examples/ for a runnable script (no server required).
Development
composer build # full gate: validate + normalize + cs + psalm + test composer cs:fix # auto-fix code style composer psalm # static analysis composer test # run tests
License
BSD-3-Clause. See LICENSE.md.