Skip to content

llms.txt — rasuvaeff/yii3-ab-testing-db ​

The compact reference the package ships for AI coding assistants. Raw file: llms.txt.

markdown
# yii3-ab-testing-db

## Install

```bash
composer require rasuvaeff/yii3-ab-testing-db
```

## Rules

- Implements `Rasuvaeff\Yii3AbTesting\ExperimentProvider` — returns `array<string, Experiment>`.
- Reads the entire experiment set in one query via yiisoft/db `Query` builder (`SELECT *`).
- `variants` column is a JSON object `{"variant":weight}`; weights are non-negative integers; `fallback_variant` must be one of the keys.
- Empty `salt` falls back to the experiment `name`.
- Invalid row shape / malformed variants or recursively malformed targeting / invalid experiment (bad name, unknown fallback, zero total weight) throws `InvalidExperimentRowException` (never silently skips; raw TypeError/core errors do not leak).
- `CachedExperimentProvider` validates the full `array<string, Experiment>` registry; poisoned arrays and cache failures fall back to inner. Its PSR-16 key is SHA-256 namespaced by DB table by default; pass `namespace` for tenant/connection isolation when sources share a table name. The Yii config-plugin enables it by default (`cache.enabled=false` disables it; TTL defaults to 60 seconds).
- Assignment hashing, fallback, forced/disabled logic — all remain in core.
- Ships namespaced `yiisoft/db-migration` migrations under `Rasuvaeff\Yii3AbTestingDb\Migration\`; register with `setSourceNamespaces()`.
- Table names flow params (`table_prefix` + `table` / `assignments_table`) => `AbExperimentsTableName` / `AbAssignmentsTableName` (VOs) => providers, repository, assignment store and migrations. Configuring a migration through the container does NOT work: `Injector::make()` resolves by type, not by class-id.
- `LastKnownGoodExperimentProvider::hasLastKnownGood()` reports whether a fallback exists; `servedStaleOnLastRead()` reports whether the latest read used it.
- Binds `ExperimentProvider` in `config/di.php` (one source only — not the `AbTesting` facade).
- Binds `ExperimentRepository`; mutations use optimistic revision checks and invalidate cache after commit.
- Runtime DB experiments expose `configurationId` as `db:<revision>`.

## Database schema

Create the table via the shipped migrations (register the namespace in
`yiisoft/db-migration` `sourcePaths`, run `./yii migrate:up`), or manually:

```sql
CREATE TABLE ab_experiments (
    name             VARCHAR(190) PRIMARY KEY,
    enabled          BOOLEAN      NOT NULL DEFAULT TRUE,
    salt             VARCHAR(190) NOT NULL DEFAULT '',
    fallback_variant VARCHAR(190) NOT NULL DEFAULT '',
    variants         TEXT         NOT NULL DEFAULT '{}',
    targeting        TEXT         NULL,
    state            VARCHAR(20)  NOT NULL DEFAULT 'running',
    revision         INTEGER      NOT NULL DEFAULT 1,
    created_at       VARCHAR(32)  NOT NULL,
    updated_at       VARCHAR(32)  NOT NULL
);
```

## API

### DbExperimentProvider

```php
use Rasuvaeff\Yii3AbTestingDb\DbExperimentProvider;

$provider = new DbExperimentProvider(
    db: $connection,             // Yiisoft\Db\Connection\ConnectionInterface
    table: 'ab_experiments',     // optional, default 'ab_experiments'
);

$experiments = $provider->getExperiments(); // array<string, Experiment>
```

### CachedExperimentProvider

```php
use Rasuvaeff\Yii3AbTestingDb\CachedExperimentProvider;

$cached = new CachedExperimentProvider(
    inner: $dbProvider,          // ExperimentProvider
    cache: $psr16,               // Psr\SimpleCache\CacheInterface
    ttl: 60,                     // int<0, max> seconds
    namespace: null,             // ?non-empty-string; table-derived for DB provider
);

$experiments = $cached->getExperiments(); // from cache or inner
$cached->clear();                // invalidate cache
```

### InvalidExperimentRowException

```php
use Rasuvaeff\Yii3AbTestingDb\Exception\InvalidExperimentRowException;
```

Thrown when a DB row is missing required columns, has wrong types, contains
malformed `variants` JSON or a malformed recursive `targeting` tree, or yields an
invalid experiment (bad name, unknown fallback variant, zero total weight,
negative weight). Core validation errors are wrapped into this exception.

### ExperimentRepository

```php
use Rasuvaeff\Yii3AbTesting\Experiment;
use Rasuvaeff\Yii3AbTestingDb\ExperimentState;

$record = $repository->create(
    new Experiment('checkout', false, 'checkout-v1', 'control', ['control' => 50, 'green' => 50]),
    ExperimentState::Draft,
);
$record = $repository->enable('checkout', $record->revision);
$record = $repository->reweight('checkout', ['control' => 10, 'green' => 90], $record->revision);
$repository->archive('checkout', $record->revision);
```

Methods: `list`, `get`, `create`, `upsert`, `enable`, `disable`, `reweight`,
`archive`. Every read and write returns an `ExperimentRecord` (`experiment`,
`state`, `revision`, `createdAt`, `updatedAt`, `schedule`); pass
`$record->revision` back as the expected revision. Stale writes throw
`RevisionConflictException`; missing records throw `ExperimentNotFoundException`;
`create()` of an existing name throws `ExperimentAlreadyExistsException`.

`DbExperimentRepository` is the implementation the config-plugin binds. Its
optional `cacheInvalidator` (`ExperimentCacheInvalidator`, implemented by
`CachedExperimentProvider`) is called only after a write commits.

`ExperimentSchedule` (`startsAt`, `endsAt`, `isActiveAt()`, `open()`) is the
planned run window — planning data only, never an assignment input: the served
variant must not depend on wall-clock time.

### Console commands

| Command | Class |
|---|---|
| `ab-testing:validate` | `ValidateExperimentsCommand` |
| `ab-testing:list` | `ListExperimentsCommand` |
| `ab-testing:create` | `CreateExperimentCommand` |
| `ab-testing:enable` | `EnableExperimentCommand` |
| `ab-testing:disable` | `DisableExperimentCommand` |
| `ab-testing:reweight` | `ReweightExperimentCommand` |

Mutating commands require the expected revision argument.

### DbAssignmentStore

```php
use Rasuvaeff\Yii3AbTestingDb\DbAssignmentStore;
use Rasuvaeff\Yii3AbTestingWeb\StickyAssignmentResolver;

$store = new DbAssignmentStore(db: $connection);           // table 'ab_assignments'
$resolver = new StickyAssignmentResolver(resolver: $ab, store: $store);

$store->forget($subjectId);          // erase a subject across experiments; returns rows deleted
$store->deleteExperiment('checkout'); // drop every pin of one experiment
```

Server-side sticky assignments keyed by `(experiment, subject_id)` — survive
cleared cookies and device switches. Implements core's
`ConfigurationAwareAssignmentStore`: a pin is reused only for the same
`configurationId`, so a reweight drops stale pins.

### Migrations

Namespace `Rasuvaeff\Yii3AbTestingDb\Migration`, registered through
`setSourceNamespaces()` (needs `yiisoft/db-migration` ^2.1):
`M260610000000CreateAbExperimentsTable`, `M260619000001AddTargetingToAbExperiments`,
`M260731000000AddOperationalFieldsToAbExperiments`,
`M260801000000CreateAbAssignmentsTable`, `M260801000001AddScheduleToAbExperiments`.
Table names come from `AbExperimentsTableName` / `AbAssignmentsTableName`.

### Usage with AbTesting

```php
use Rasuvaeff\Yii3AbTesting\AbTesting;
use Rasuvaeff\Yii3AbTesting\WeightedHashAssignmentStrategy;
use Rasuvaeff\Yii3AbTestingDb\DbExperimentProvider;

$ab = new AbTesting(
    provider: new DbExperimentProvider(db: $connection),
    strategy: new WeightedHashAssignmentStrategy(),
);

$ab->assign(experiment: 'checkout-button', subjectId: $userId);
```