llms.txt — rasuvaeff/yii3-ab-testing-web
The compact reference the package ships for AI coding assistants. Raw file: llms.txt.
markdown
# yii3-ab-testing-web
## Install
```bash
composer require rasuvaeff/yii3-ab-testing-web
```
## Rules
- Requires core `^1.6` (`AssignmentResolver`, `Assignment::configurationId`).
- Assignment is deterministic in `subjectId`; a stable id keeps a visitor in the same variant across visits without storing anything.
- `SubjectIdMiddleware` (PSR-15): pre-set authenticated id, valid `ab_id`, or generated opaque id (`random_bytes(16)` hex). `SubjectIdRequestAccessor` returns typed `SubjectId`; legacy `ab.subjectId` string remains populated.
- Pass the same `ConsentPolicyInterface` to subject and sticky middleware. Denial ignores both persistent cookies, creates `SubjectIdSource::Ephemeral`, and writes no cookies — except a deletion (`Expires` in the past, same `Secure`/`SameSite`/`Path`) for an `ab_id`/`ab_variants` the request still carries. No such cookie in the request, no header. `CallbackConsentPolicy` adapts `callable(ServerRequestInterface): bool`; `AllowAllConsentPolicy` is the backward-compatible default.
- `AnonymousToAuthenticatedStrategy`: `MigrateAssignments` (default: user id + existing browser assignments), `UseAuthenticatedId` (user id + fresh assignments), `KeepAnonymousId` (anonymous id + existing assignments).
- `CookieAssignmentStore implements ConfigurationAwareAssignmentStore`: signed sticky variants, request-scoped via `fromRequest()` / `applyToResponse()`. Defaults: max 50 entries, max 3800 actual `Set-Cookie` bytes. Deterministic FIFO eviction; update moves entry to newest. Oversized input is rejected before JSON decode. Browser-scoped → `$subjectId` ignored.
- Configuration-aware entries store core `configurationId`; mismatches are misses. Cookie v1 `{experiment: "variant"}` remains readable.
- `StickyAssignmentMiddleware`: ready PSR-15 glue; run after `SubjectIdMiddleware`. It exposes resolver/store via `StickyAssignmentRequestAccessor` and applies changed cookies to the response.
- `StickyAssignmentResolver` precedence: disabled → fallback before forced/store (kill switch wins); on enabled experiments forced bypasses targeting/store; otherwise core targeting runs before store and mismatch → fallback with no store read/write; eligible stored variant is reused only while valid (`isSticky = true`); fallbacks are not stored. Keeps `AbTesting::assign()` pure.
- `SubjectIdMiddleware` reuses the `ab_id` cookie only when `SubjectIdGeneratorInterface::isValid()` accepts it; foreign values are regenerated.
- `SubjectIdGeneratorInterface` = `generate()` + `isValid()`. Both halves or none: a generator whose `isValid()` rejects its own output makes the middleware mint a new id every request, flipping the visitor's variant on every page view. Default `HexSubjectIdGenerator` = 32 lowercase hex, anchored with `\z` (`$` would also match a trailing newline). `isValid()` is a security boundary — the cookie is attacker-controlled and whatever passes becomes the subject id in logs and analytics.
- To provide a logged-in id, upstream middleware sets legacy `ab.subjectId` or uses `SubjectIdRequestAccessor` with `SubjectIdSource::Authenticated`.
- `CookieAssignmentStore::prune(ExperimentRegistry)` drops variants of removed experiments; call before `applyToResponse()`.
- No `config/di.php`: the store is request-scoped and middleware are added by the app — nothing to bind.
## API
### SubjectIdMiddleware
```php
use Rasuvaeff\Yii3AbTestingWeb\SubjectIdMiddleware;
$mw = new SubjectIdMiddleware(
cookieName: 'ab_id', // optional
attribute: 'ab.subjectId', // optional
// maxAge: DateInterval, secure: bool, sameSite: Cookie::SAME_SITE_*
// idGenerator: SubjectIdGeneratorInterface (default HexSubjectIdGenerator)
// consentPolicy: ConsentPolicyInterface
// identityTransition: AnonymousToAuthenticatedStrategy
);
interface SubjectIdGeneratorInterface
{
/** @return non-empty-string */
public function generate(): string;
public function isValid(string $id): bool;
}
// in handler:
$subjectId = (new SubjectIdRequestAccessor())->require($request); // SubjectId
```
### CookieAssignmentStore
```php
use Rasuvaeff\Yii3AbTestingWeb\CookieAssignmentStore;
use Yiisoft\Cookies\CookieSigner;
$store = CookieAssignmentStore::fromRequest(
request: $request,
signer: new CookieSigner($secretKey),
maxEntries: 50,
maxCookieBytes: 3800,
);
$store->get('checkout-button', $subjectId); // ?string
$store->put('checkout-button', $subjectId, 'green');
$response = $store->applyToResponse($response); // writes signed cookie if changed
```
### StickyAssignmentMiddleware
```php
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentMiddleware;
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentRequestAccessor;
$middleware = new StickyAssignmentMiddleware(
resolver: $ab,
signer: new CookieSigner($secretKey),
consentPolicy: $consentPolicy,
);
$resolver = (new StickyAssignmentRequestAccessor())->resolver($request);
$assignment = $resolver->resolve(
experiment: 'checkout-button',
subjectId: $subjectId->value,
forcedVariant: null, // optional, bypasses the store
context: null, // optional AssignmentContext
);
```
### SignedReceiptCodec
```php
use Rasuvaeff\Yii3AbTestingWeb\SignedReceiptCodec;
$codec = new SignedReceiptCodec(secret: $receiptSecret); // >= 32 bytes, server-only
$forClient = $codec->encode($exposure->receipt()); // signed string for a JSON body / localStorage
$receipt = $codec->decode($postedValue); // ?AssignmentReceipt — null when the signature fails
if ($receipt !== null) {
$ab->trackConversionForReceipt($receipt, goal: 'purchase');
}
```
Signs a core `AssignmentReceipt` independently of the transport, for SPA flows
where the receipt comes back in a request body. Never trust an unsigned receipt:
a client could claim variant B while assigned A. Do not re-resolve the
assignment instead — after a reweight it returns the variant the visitor would
get now, not the one they saw. Rotating the secret invalidates outstanding
receipts. Runnable: `examples/spa-endpoints.php`.