PHPackages                             cleatsquad/php-bandit - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. cleatsquad/php-bandit

ActiveLibrary

cleatsquad/php-bandit
=====================

Beta-Bernoulli Thompson Sampling multi-armed bandit, in dependency-free PHP

v1.0.1(today)04↑2900%[1 issues](https://github.com/CleatSquad/php-bandit/issues)[1 PRs](https://github.com/CleatSquad/php-bandit/pulls)MITPHPPHP &gt;=8.2CI passing

Since Aug 14Pushed todayCompare

[ Source](https://github.com/CleatSquad/php-bandit)[ Packagist](https://packagist.org/packages/cleatsquad/php-bandit)[ Docs](https://github.com/cleatSquad/php-bandit)[ RSS](/packages/cleatsquad-php-bandit/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (2)Versions (4)Used By (0)

PHP Bandit
==========

[](#php-bandit)

[![Latest Version](https://camo.githubusercontent.com/48026d5fe4cb57228bcbfd5dc9c5c9c71e9e5b3747d3424ac52e7db4df52c080/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f636c65617473717561642f7068702d62616e6469742e737667)](https://packagist.org/packages/cleatsquad/php-bandit)[![License: MIT](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](LICENSE)[![PHP Version](https://camo.githubusercontent.com/a04c4ba987a1efdcd8ce8fe345636b2eef0a31fdddb79b9b3f654dd38e4b2fb9/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253345253344382e322d3737376262342e737667)](composer.json)

Beta-Bernoulli Thompson Sampling for multi-armed bandit problems, in dependency-free PHP.

You have several options and no idea which performs best. Testing them evenly wastes traffic on the losers; committing early to the leader may commit to a fluke. Thompson Sampling resolves that trade-off by drawing from each option's posterior belief and picking the winner of that draw — good options get chosen more often, uncertain ones keep getting explored.

Installation
------------

[](#installation)

```
composer require cleatsquad/php-bandit
```

Requires PHP 8.2 or later. No runtime dependencies.

Usage
-----

[](#usage)

### Pick an option

[](#pick-an-option)

```
use CleatSquad\Bandit\ArmState;
use CleatSquad\Bandit\ThompsonSamplingPolicy;

$policy = new ThompsonSamplingPolicy();

$arms = [
    'variant_a' => new ArmState(successes: 12, failures: 4),
    'variant_b' => new ArmState(successes: 30, failures: 1),
];

$result = $policy->select($arms);

$result->selectedArm; // 'variant_b' — usually, but not always
$result->sample;      // 0.9412... the draw that won
$result->samples;     // every draw: ['variant_a' => 0.7318..., 'variant_b' => 0.9412...]
```

The result is deliberately stochastic. An arm that looks worse still gets picked occasionally — that is the exploration that stops the bandit from locking onto an early fluke.

Keeping the draws makes a decision explainable after the fact: log `$result->samples` and you can replay why that arm won, without drawing again. When you only need the key, use the shorthand:

```
$policy->selectArm($arms); // 'variant_b'
```

There is no "was this exploration or exploitation?" flag, because Thompson Sampling has no such mode — every pick is one draw per arm. If you want to know whether the winner was the current front-runner, compare it yourself:

```
$means = array_map(
    static fn (ArmState $s): float => $policy->posteriorMean($s->successes, $s->failures),
    $arms,
);

$frontRunner = array_search(max($means), $means, strict: true);
$exploredAway = $frontRunner !== $result->selectedArm;
```

### Run the loop

[](#run-the-loop)

Selecting is one fifth of the job. The full cycle is **select → execute → observe → update → persist**:

```
// 1. select — the library decides, from state you hand it
$arms   = $yourStorage->loadArms();          // array
$result = $policy->select($arms);

// 2. execute — outside the library: serve the variant, call the provider…
$outcome = $yourSystem->run($result->selectedArm);

// 3. observe — reduce the outcome to a binary success or failure
$succeeded = $outcome->isSuccess();

// 4. update — immutable transition, always on the arm that played
$updated = $succeeded
    ? $arms[$result->selectedArm]->withSuccess()
    : $arms[$result->selectedArm]->withFailure();

// 5. persist — outside the library: yours to write (see below)
$yourStorage->save($result->selectedArm, $updated);
```

Three rules hold that loop together:

- **Only the arm that played is updated.** The others observed nothing.
- **An observation is binary.** This is the Bernoulli variant; threshold a continuous reward before feeding it in.
- **A late observation is still valid.** A conversion that lands a day later is applied when it arrives, to the arm's current state.

If your storage counts trials rather than failures, build the state directly:

```
$state = ArmState::fromTrials(trials: 100, successes: 63); // 63 successes, 37 failures
$state->trials();                                          // 100
```

### Persist it yourself, concurrently

[](#persist-it-yourself-concurrently)

The library is **stateless**: the policy remembers nothing between calls, `ArmState` is immutable, and nothing here touches storage. That is deliberate — keys, transactions, serialization and increment semantics belong to your application, not to a bandit.

Which leaves one mistake worth naming. Loading an `ArmState`, calling `withSuccess()`, and writing both counts back is a read-modify-write: two concurrent processes write the same value and one observation vanishes. Under concurrency, increment in the store instead:

```
UPDATE arms SET successes = successes + 1 WHERE arm = :arm;
```

`withSuccess()` and `withFailure()` remain the right API in memory — in a single process, a simulation, or a test.

Two things you do *not* need to protect against:

- **A stale read before deciding.** Choosing on state that is a few requests old slows convergence; it does not bias it. `select()` needs no lock.
- **A lost or duplicated observation.** At volume the posterior absorbs the noise. If rewards arrive asynchronously and can be replayed, deduplicate by decision id on your side.

### Inspect the posterior

[](#inspect-the-posterior)

```
$policy->posteriorMean(10, 2);     // 0.846 — expected success rate
$policy->posteriorVariance(10, 2); // how unsure that estimate is
$policy->posteriorWeight(10, 2);   // 0.0..1.0 confidence, 0 when uninformed
$policy->sample(10, 2);            // one random draw from Beta(11, 3)
```

### Reproducible runs

[](#reproducible-runs)

```
$policy = ThompsonSamplingPolicy::withSeed(42);
$policy->sample(5, 2); // same value on every run, for tests and simulations
```

Design notes
------------

[](#design-notes)

**Beta(1,1) prior.** Successes and failures are offset by an uninformative prior, so an arm with no data yet behaves as a coin flip rather than dividing by zero.

**Gamma draws via Marsaglia &amp; Tsang (2000).** Constant-time rejection sampling, valid for shape ≥ 1 — which the Beta(1,1) prior guarantees by construction. Beta samples are then formed as `X / (X + Y)` from two Gamma draws.

**Native randomness.** Uses PHP's `\Random\Randomizer`. Inject your own engine, or use `withSeed()` for a Mt19937 engine with a fixed seed.

**Invalid counts are rejected, not repaired.** A negative success or failure count is a caller bug, never data: `ArmState` and every statistic throw `InvalidArmStateException` rather than silently produce a wrong posterior. Every exception in this package implements `BanditException`, so `catch (BanditException $e)` catches all of them — and each one still extends its natural SPL class.

ExceptionThrown when`InvalidArmStateException`a count is negative, or successes exceed trials`EmptyArmSetException``select()` is given no candidate arm`InvalidSelectionException`a `SelectionResult` is built from a decision that could not have happened**A result cannot contradict itself.** `SelectionResult` validates its own bookkeeping: the samples are non-empty, the selected arm was drawn, and `$sample` is the draw that arm got. The winning draw is *not* required to be the largest one — a policy other than Thompson Sampling may pick against its own samples, and `SelectionResult` is the shared return type of `BanditPolicyInterface`.

Statistical validation
----------------------

[](#statistical-validation)

A sampler can pass every unit test and still draw from the wrong curve, so the suite checks the distribution itself. Every run below is seeded: the assertions are deterministic, not flaky.

- **Goodness of fit.** Draws are compared to the exact Beta CDF with a Kolmogorov-Smirnov test at the 99% level, across uninformed, balanced, skewed and highly concentrated posteriors.
- **Moments.** Empirical mean and variance are matched against `posteriorMean()` and `posteriorVariance()`, the variance on a relative tolerance since it spans four orders of magnitude across those posteriors.
- **Regret.** A full bandit episode of 20,000 rounds is simulated against known conversion rates. Cumulative regret has to grow sublinearly: regret per round keeps falling, and the second half of a run costs less than the first.
- **Invariants.** Laws that hold for every input — draws inside the open unit interval, trials equal to successes plus failures, a selection only ever reporting arms it was given, a seed always replaying the same decision — are checked on generated cases rather than hand-picked ones.

Performance
-----------

[](#performance)

Measured with PHPBench on PHP 8.4, one decision per operation:

OperationCost`select()` over 2 arms~3.3 μs`select()` over 10 arms~14 μs`select()` over 100 arms~146 μs`sample()`~1.3 μs`posteriorMean()`~0.2 μsCost is linear in the number of arms and flat in the amount of evidence: a posterior backed by ten thousand observations draws as fast as an uninformed one. Run `composer bench` for the numbers on your own hardware.

When to use it
--------------

[](#when-to-use-it)

Good fits: A/B and multivariate testing, traffic allocation, ranking candidate strategies, model or provider selection, any explore-versus-exploit choice with a binary outcome.

Poor fits: rewards that are not success/failure (this is the Bernoulli variant), or a setting where a single decision must be reproducible without a fixed seed.

Stability
---------

[](#stability)

`1.0.0` freezes the public API. From here on the package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) strictly: nothing listed below changes without a major release.

- The signatures of `ThompsonSamplingPolicy`, `ArmState`, `SelectionResult` and `BanditPolicyInterface`, including their public properties.
- `BanditException` as the marker every exception here implements, and each exception's SPL parent class.
- Which exception a given invalid input throws.

Three things are explicitly *not* covered, and may change in a minor release:

- **The draws a given seed produces.** `withSeed()` guarantees that two runs of the same version agree, not that two versions agree. Improving the sampler is a bug fix, not a breaking change. Assert on distributions, not on values.
- **Exception messages.** Catch the class, do not match the text.
- **Anything marked `private`**, including how the posterior is drawn from.

Upgrading to 1.0.0
------------------

[](#upgrading-to-100)

`1.0.0` removes `selectBestArm()`, deprecated since `0.2.0`. Replace it with `selectArm()`; the behaviour is identical.

```
$policy->selectBestArm($arms); // gone in 1.0.0
$policy->selectArm($arms);     // same decision, honest name
```

Two smaller changes, neither of which affects correct code:

- `SelectionResult` now rejects an inconsistent decision at construction time with `InvalidSelectionException`. Only code building results by hand — a test double, a custom policy — can hit this, and only when the result was already wrong.
- Uniform draws are taken over 2^53 rather than `PHP_INT_MAX`, so the top of the interval can no longer round to exactly `1.0`. Drawn sequences change for a given seed; distributions do not. Pin the version if you assert on exact seeded values.

Upgrading from 0.1.0
--------------------

[](#upgrading-from-010)

`0.2.0` reshapes the public API. The maths are untouched: same algorithm, same draws, same numbers.

`0.1.0``0.2.0``$policy->selectBestArm($arms)``$policy->selectArm($arms)`, or `$policy->select($arms)->selectedArm``BanditPolicyInterface` declared the four statisticsit declares `select(array $arms): SelectionResult`; the statistics stay on `ThompsonSamplingPolicy`type-hinting the interface to call `posteriorWeight()`type-hint `ThompsonSamplingPolicy`, or declare your own application interface`new ArmState($s->successes + 1, $s->failures)``$s->withSuccess()``new ArmState($successes, $trials - $successes)``ArmState::fromTrials($trials, $successes)`negative counts were clamped to zerothey throw `InvalidArmStateException``selectBestArm()` still works in `0.2.0`, deprecated. It is gone in `1.0.0`: the name promised an `argmax`, while what it returns is a posterior draw.

Testing
-------

[](#testing)

```
composer install
composer test      # PHPUnit
composer analyse   # PHPStan, max level
composer bench     # PHPBench
composer mutation  # Infection, needs pcov or xdebug
```

`bench` and `mutation` install their own toolchain under `tools/` on first use. They are kept out of `require-dev` on purpose: the package is tested on PHP 8.2 through 8.5, and neither tool has to be installable on all of them for the matrix to run.

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance100

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 91.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

3

Last Release

0d ago

Major Versions

v0.2.0 → v1.0.12026-08-14

### Community

Maintainers

![](https://www.gravatar.com/avatar/75b8cb31786be9b4017a0c617eebe3a0cd3b8d039069ffad5bb5007b5510fd9d?d=identicon)[mimou78](/maintainers/mimou78)

---

Top Contributors

[![mohaelmrabet](https://avatars.githubusercontent.com/u/3817628?v=4)](https://github.com/mohaelmrabet "mohaelmrabet (21 commits)")[![renovate[bot]](https://avatars.githubusercontent.com/in/2740?v=4)](https://github.com/renovate[bot] "renovate[bot] (2 commits)")

---

Tags

statisticsab-testingmulti armed banditbanditreinforcement learningthompson-samplingbeta-bernoulli

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/cleatsquad-php-bandit/health.svg)

```
[![Health](https://phpackages.com/badges/cleatsquad-php-bandit/health.svg)](https://phpackages.com/packages/cleatsquad-php-bandit)
```

###  Alternatives

[markrogoyski/math-php

Math Library for PHP. Features descriptive statistics and regressions; Continuous and discrete probability distributions; Linear algebra with matrices and vectors, Numerical analysis; special mathematical functions; Algebra

2.4k7.8M58](/packages/markrogoyski-math-php)[wnx/laravel-stats

Get insights about your Laravel Project

1.7k1.9M7](/packages/wnx-laravel-stats)[rubix/tensor

A library and extension that provides objects for scientific computing in PHP.

2801.6M5](/packages/rubix-tensor)[szymach/c-pchart

Port of "pChart" library into PHP 8+

1513.3M32](/packages/szymach-c-pchart)[hi-folks/statistics

PHP package that provides functions for calculating mathematical statistics of numeric data.

403126.0k](/packages/hi-folks-statistics)[bilfeldt/laravel-route-statistics

Log statistics about route usage per user/team

240154.2k2](/packages/bilfeldt-laravel-route-statistics)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
