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: ^2.0
- 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-08-01 17:04:38 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. Projects using the llm/skills Composer plugin also get this package's agent skill synced into
.agents/skills/automatically on install.
Assembling a combination? The family's integration matrix lives in the core:
vendor/rasuvaeff/yii3-ab-testing/docs/integration.md.
Requirements
- PHP 8.3+
rasuvaeff/yii3-ab-testing^1.6 (AssignmentResolverand configuration ids)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
Upgrading from 1.x? See UPGRADE.md.
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 across requests | StickyAssignmentMiddleware |
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 through SubjectIdRequestAccessor (and keeps the historical string
attribute ab.subjectId for compatibility):
- 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; it is persisted as an
HttpOnly,SameSite=Laxcookie only when the consent policy permits persistence.
use Rasuvaeff\Yii3AbTestingWeb\CallbackConsentPolicy; use Rasuvaeff\Yii3AbTestingWeb\SubjectIdMiddleware; use Rasuvaeff\Yii3AbTestingWeb\SubjectIdRequestAccessor; $consent = new CallbackConsentPolicy( static fn ($request): bool => $request->getAttribute('analyticsConsent') === true, ); $middleware = new SubjectIdMiddleware(consentPolicy: $consent); // in your action/handler: $subjectId = (new SubjectIdRequestAccessor())->require($request); $assignment = $ab->resolve(experiment: 'checkout-button', subjectId: $subjectId->value);
Before consent, incoming identity and assignment cookies are ignored, a fresh
SubjectIdSource::Ephemeral id exists only for the request, and no cookie is
written. AllowAllConsentPolicy remains the default for backward compatibility;
applications that require consent must pass their policy to both middleware.
Anonymous to authenticated identity
Configure identityTransition when auth middleware supplies ab.subjectId and
an anonymous ab_id cookie also exists:
| Strategy | Subject id after login | Existing browser assignments |
|---|---|---|
MigrateAssignments (default) |
authenticated id | retained |
UseAuthenticatedId |
authenticated id | discarded and started afresh |
KeepAnonymousId |
anonymous cookie id | retained |
use Rasuvaeff\Yii3AbTestingWeb\AnonymousToAuthenticatedStrategy; $middleware = new SubjectIdMiddleware( consentPolicy: $consent, identityTransition: AnonymousToAuthenticatedStrategy::UseAuthenticatedId, );
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
Use the ready PSR-15 StickyAssignmentMiddleware after SubjectIdMiddleware.
It creates a request-scoped CookieAssignmentStore, exposes an
AssignmentResolver, and applies changed assignments to the response:
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentMiddleware; use Yiisoft\Cookies\CookieSigner; $identityMiddleware = new SubjectIdMiddleware(consentPolicy: $consent); $stickyMiddleware = new StickyAssignmentMiddleware( resolver: $ab, signer: new CookieSigner($secretKey), consentPolicy: $consent, maxEntries: 50, maxCookieBytes: 3800, );
Then in your action:
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentRequestAccessor; use Rasuvaeff\Yii3AbTestingWeb\SubjectIdRequestAccessor; $subjectId = (new SubjectIdRequestAccessor())->require($request); $resolver = (new StickyAssignmentRequestAccessor())->resolver($request); $assignment = $resolver->resolve( experiment: 'checkout-button', subjectId: $subjectId->value, );
The signed cookie is capped by both maxEntries and the actual Set-Cookie
header size (maxCookieBytes). Eviction is deterministic FIFO; updating an
experiment makes it newest. Oversized incoming cookies are rejected before JSON
decoding. Entries carry core's configurationId, so a changed experiment
definition invalidates its old sticky assignment. The v1 string-map format is
still accepted.
StickyAssignmentResolver implements core AssignmentResolver. Fallback,
forced and targeting decisions return before store access; disabled experiments
remain a kill switch. AbTesting::assign() stays pure.
API reference
| Class | Description |
|---|---|
SubjectIdMiddleware |
PSR-15 middleware; stable subject id + ab_id cookie |
SubjectId, SubjectIdSource |
typed identity value and anonymous/authenticated/ephemeral source |
SubjectIdRequestAccessor |
typed request access while preserving ab.subjectId compatibility |
ConsentPolicyInterface |
persistence decision; use CallbackConsentPolicy for application consent |
AnonymousToAuthenticatedStrategy |
keep anonymous, use authenticated, or migrate assignments |
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() |
SignedReceiptCodec |
Signs an AssignmentReceipt into a transport-independent string, so a SPA can return it in a JSON body |
StickyAssignmentMiddleware |
ready request-scoped cookie store + sticky resolver PSR-15 integration |
StickyAssignmentRequestAccessor |
typed access to the request resolver and store |
StickyAssignmentResolver |
AssignmentResolver decorator over any core resolver + store |
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. A denied consent policy prevents both identity and sticky-cookie reads/writes. - 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 sticky cookie is browser-scoped: the
$subjectIdargument of the store is ignored. Choose the authenticated transition explicitly for your product.
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.