ruafozy/mersenne-twister
A pure-PHP implementation of the Mersenne Twister (MT19937) pseudorandom number generator, offering seedable, deterministic random numbers for reproducible sequences or compatibility with existing MT output.
composer require ruafozy/mersenne-twister
The package is a straight port of the reference MT19937 implementation: one class, no service provider, no config file, no facade, no Blade directives. There is nothing to register — Composer autoloading is the entire integration. Look first at the single class file under vendor/ruafozy/mersenne-twister/src/; it exposes the classic reference primitives (int31(), int32(), real1()/real2()/real3(), res53()) over the 624-word internal state, seeded through the constructor. Everything else you need — range(), shuffle(), pick() — you build yourself.
The one reason to reach for this over mt_rand(), random_int(), or Str::random() is reproducibility: the same seed yields the same sequence on every machine, every PHP version, every deploy. PHP 7.1 silently changed the algorithm behind mt_rand() (the "modulo bias" fix), so native output is not stable across runtimes. This package pins the sequence.
First use case — a reproducible shuffle so a user's "randomly ordered" dashboard doesn't reshuffle on every refresh:
use Ruafozy\MersenneTwister\MersenneTwister;
$mt = new MersenneTwister(crc32("feed:{$user->id}"));
$order = $feedItems;
for ($i = count($order) - 1; $i > 0; $i--) {
$j = $mt->int31() % ($i + 1);
[$order[$i], $order[$j]] = [$order[$j], $order[$i]];
}
That six-line snippet is roughly 80% of real-world usage. Treat the package as a deterministic primitive, not a replacement for PHP's random functions.
Bind it in the container, and use scoped(), not singleton().
// app/Providers/DeterministicRandomServiceProvider.php
public function register(): void
{
$this->app->scoped(MersenneTwister::class, fn ($app) =>
new MersenneTwister((int) $app['config']->get('app.det_random_seed', random_int(1, PHP_INT_MAX)))
);
}
scoped() gives you one generator per request/job and is flushed between requests under Octane. singleton() under Octane leaks the stream across requests, which silently breaks per-request reproducibility.
Wrap it behind your own interface. This is the single highest-value pattern given the package's age and zero dependents. Define App\Support\Random\DeterministicRandom with int(), real(), shuffle(), pick(), inject it everywhere, and let the Mersenne Twister be an implementation detail. Swapping to a maintained port — or to native mt_rand() — later becomes a one-file change.
Add rejection sampling on top of int31(). Naive % $span introduces modulo bias; for a 6-sided die it's negligible, for a 1000-way bucketing it isn't:
public function int(int $min, int $max): int
{
$span = $max - $min + 1;
$limit = intdiv(0x7FFFFFFF, $span) * $span;
do { $n = $this->mt->int31(); } while ($n >= $limit);
return $min + ($n % $span);
}
Derive seeds, don't store them. crc32($entity->id . ':' . $purpose) gives you stable, per-entity, per-feature seeds with no schema changes. Use a different purpose string per feature ('feed', 'ab:checkout-v2', 'sampling:analytics') so streams are independent. If the seed must be unguessable, use a keyed hash (hash_hmac('sha256', $id, config('app.key')) truncated to 32 bits) — but see the security caveat below.
Queue jobs: generate the seed at dispatch, pass it in the payload. The worker process has a different PRNG lifecycle; a seed embedded in the job makes the job's output reproducible even if it retries or is replayed from Horizon.
final class BuildRecommendations implements ShouldQueue
{
public function __construct(public int $seed) {}
public function handle(DeterministicRandom $random): void { /* seeded per job */ }
}
Testing is where this pays for itself. Snapshot tests, golden-file tests, and "this user got variant B in staging but A locally" bugs all disappear when the seed is fixed in phpunit.xml (<env name="DET_RANDOM_SEED" value="42"/>) or overridden per test with $this->app->instance(MersenneTwister::class, new MersenneTwister(42)).
Seeders and fixtures. Laravel's Faker is already seedable ($faker->seed()), so use this package for seeders only if you're generating derived data (shuffled pivot rows, deterministic timestamps) rather than Faker values. Don't double up.
A/B assignment. A short hash of user ID plus experiment name into real2() < $trafficFraction gives you stable, sticky, database-free bucketing — a legitimate production use case for this package.
random_bytes()/Str::random() remain the answer there. Derived seeds from user IDs are guessable by design.mt_rand(). Fine for per-request shuffles of hundreds of items; do not put it in a loop over 100k rows. If you need bulk deterministic values, generate them once and cache them.int32() returns signed values. You'll get negative numbers; mask with & 0xFFFFFFFF before doing unsigned arithmetic. Watch for overflow on 32-bit PHP builds, which still exist in some CI images.real1/2/3() give ~32 bits of precision; only res53() gives 53. Don't use real*() for high-precision sampling or for anything compared with strict equality on floats.getState()/setState(), the only safe unit of reproducibility is "one seed per unit of work."composer.json, and keep the wrapper interface so you can vendor the ~200 lines or replace them. Verify it against your PHP version (8.2/8.3) before adopting — older ports sometimes emit deprecation notices from dynamic properties or removed functions; run the test suite with E_ALL once.init_by_array, not init_genrand). If you're matching a Python service or a JS client, verify sequences explicitly with a known seed before committing to it.Log::withContext() or the job payload. "Why did this user see a different order?" is answerable in seconds if the seed is in the logs and unanswerable otherwise.shuffle(), weightedPick(), or getState(), or (preferred) compose it inside your own DeterministicRandom service and keep the dependency contained.How can I help you explore Laravel packages today?