PHP Randomizer Engines: Choosing Between Secure, Seeded and Fast Randomness in Laravel

Which PHP Randomizer engine to pick: Secure for tokens, seeded Xoshiro256StarStar for tests. Inject a Randomizer in Laravel and kill flaky random tests.

Steven Richardson
Steven Richardson
· 9 min read

Two things go wrong with randomness in PHP applications, and they share a cause. Tokens get generated with mt_rand() or str_shuffle() because nobody checked which functions are actually unguessable. And any code that calls a global random function is untestable, so you either assert loosely or accept a flaky test. Picking the right PHP Randomizer engine solves both at once.

How the PHP Randomizer engine API is put together#

The random extension splits randomness into two objects. Random\Randomizer provides the operations: get an integer, get bytes, shuffle an array. A Random\Engine implementation provides the raw randomness and therefore decides the properties: predictable or not, reproducible or not, fast or slow.

The engine interface is tiny:

namespace Random;

interface Engine
{
    public function generate(): string;
}

// Marker interface: engines safe for cryptographic use.
interface CryptoSafeEngine extends Engine {}

That is the whole contract. Randomizer takes one in its constructor and exposes it as a readonly property:

use Random\Engine\Secure;
use Random\Randomizer;

// No engine argument means Secure. This is the right default.
$randomizer = new Randomizer();

// Explicit, and identical in behaviour.
$randomizer = new Randomizer(new Secure());

$randomizer->getInt(1, 100);

Randomizer is final, so you cannot subclass it to change behaviour. That is deliberate: the swappable part is the engine, not the operations. It is the strategy pattern with a one-method interface, the same shape I use when PHP enums implement interfaces to select behaviour.

Which PHP Randomizer engine for which job#

Four engines ship with PHP. Only one of them is a valid choice in production.

Engine Seedable Crypto-safe Reach for it when
Random\Engine\Secure No Yes Always, unless you specifically need reproducibility
Random\Engine\Xoshiro256StarStar Yes — int or 32 bytes No Reproducible tests, simulations; fastest of the four
Random\Engine\PcgOneseq128XslRr64 Yes — int or 16 bytes No Reproducible tests where you also want jump()
Random\Engine\Mt19937 Yes — 32-bit int No Reproducing legacy mt_rand() output, nothing else

Notice that "seedable" and "crypto-safe" never appear in the same row. That is not an implementation gap, it is the definition. A seeded engine's entire output is a pure function of its seed, so anyone who recovers the seed can replay every value you will ever generate. Secure has no seed parameter at all — it reads from the operating system CSPRNG, the same source as random_bytes().

Mt19937 deserves a specific warning. Its seed is a 32-bit integer, so there are 4,294,967,296 possible sequences. Birthday maths puts you at a 50% chance of two seeds colliding after roughly 77,000 of them. If you are seeding per request, per job or per test case, accidental sequence reuse is not theoretical. When you genuinely need reproducibility, Xoshiro256StarStar and PcgOneseq128XslRr64 accept much larger seeds and cost you nothing extra.

Here is the reproducibility property, made concrete:

use Random\Engine\Secure;
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;

// Different on every run, on every machine.
$secure = new Randomizer(new Secure());
$secure->getInt(1, 1_000_000);

// hash(..., binary: true) returns exactly the 32 bytes Xoshiro requires.
$seed = hash('sha256', 'order-simulation-2026', binary: true);

$a = new Randomizer(new Xoshiro256StarStar($seed));
$b = new Randomizer(new Xoshiro256StarStar($seed));

// Byte-identical, now and in six months.
var_dump($a->getInt(1, 1_000_000) === $b->getInt(1, 1_000_000)); // bool(true)

The Randomizer methods worth knowing#

Every global random function has a Randomizer equivalent, and the equivalents behave better because they respect the engine you handed over.

Randomizer method Replaces Available
getInt(int $min, int $max) random_int(), mt_rand() 8.2
nextInt() mt_rand() with no arguments 8.2
getBytes(int $length) random_bytes() 8.2
shuffleArray(array $array) shuffle() 8.2
shuffleBytes(string $bytes) str_shuffle() 8.2
pickArrayKeys(array $array, int $num) array_rand() 8.2
getBytesFromString(string $string, int $length) hand-rolled substr() loops 8.3
getFloat(float $min, float $max, IntervalBoundary $boundary) lcg_value(), mt_rand() division 8.3
nextFloat() lcg_value() 8.3

shuffleArray() and shuffleBytes() return new values rather than mutating by reference, which is the correct signature and the reason I stopped reaching for shuffle().

getFloat() is the one worth a second look, because it takes an interval boundary rather than making you guess:

use Random\IntervalBoundary;
use Random\Randomizer;

$randomizer = new Randomizer();

// [0.0, 1.0) — the default, and what nextFloat() gives you.
$randomizer->getFloat(0.0, 1.0);

// (0.0, 1.0] — excludes zero, so a downstream division cannot blow up.
$randomizer->getFloat(0.0, 1.0, IntervalBoundary::OpenClosed);

// [1.0, 5.0] — both ends reachable.
$randomizer->getFloat(1.0, 5.0, IntervalBoundary::ClosedClosed);

And the thing not to do, stated plainly:

// Don't. mt_rand() output is predictable from a handful of observed values,
// and this token is short enough to brute force anyway.
$resetToken = str_pad((string) mt_rand(), 10, '0');

// Do. 32 bytes from the OS CSPRNG, and it throws rather than degrading quietly.
$resetToken = bin2hex((new Randomizer())->getBytes(32));

If that reset token ever ends up in an exception trace, the same care applies to the log output — that is what #[\SensitiveParameter] is for.

Making randomness injectable in Laravel#

The security half is now settled. The testability half comes from resolving Randomizer out of the container instead of calling global functions, then binding a different engine per environment.

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Random\Engine\Secure;
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // bind(), not singleton() — see the gotchas below.
        $this->app->bind(Randomizer::class, function (): Randomizer {
            if ($this->app->environment('testing')) {
                return new Randomizer(
                    new Xoshiro256StarStar(hash('sha256', 'test-seed', binary: true))
                );
            }

            return new Randomizer(new Secure());
        });
    }
}

Consumers then take a Randomizer in the constructor and stop caring where the randomness came from:

namespace App\Services;

use Random\Randomizer;

final readonly class InviteCodeGenerator
{
    // Crockford-ish alphabet: no I, O, 0 or 1 to mistype over the phone.
    private const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';

    public function __construct(private Randomizer $randomizer) {}

    public function generate(int $length = 10): string
    {
        return $this->randomizer->getBytesFromString(self::ALPHABET, $length);
    }
}

That final readonly class with a promoted constructor property is the same immutable shape I use for readonly value objects in Laravel: one dependency, no setters, nothing to reason about after construction.

Writing a deterministic test for random code#

This is the payoff. The generator is unchanged between environments — only the engine differs — so the test asserts real behaviour rather than a mock.

use App\Services\InviteCodeGenerator;
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;

function seededRandomizer(string $seed = 'invite-codes'): Randomizer
{
    return new Randomizer(new Xoshiro256StarStar(hash('sha256', $seed, binary: true)));
}

it('produces the same code for the same seed', function () {
    $first = (new InviteCodeGenerator(seededRandomizer()))->generate();
    $second = (new InviteCodeGenerator(seededRandomizer()))->generate();

    expect($first)->toBe($second)
        ->and($first)->toHaveLength(10);
});

it('produces a different code for a different seed', function () {
    $a = (new InviteCodeGenerator(seededRandomizer('seed-a')))->generate();
    $b = (new InviteCodeGenerator(seededRandomizer('seed-b')))->generate();

    expect($a)->not->toBe($b);
});

You can also lock the literal value, which catches accidental changes to the alphabet or the length:

it('locks the generated sequence', function () {
    $generator = new InviteCodeGenerator(seededRandomizer());

    // Run this once locally and paste in what your build actually produced.
    // Stable for a given engine and seed; it will not survive an engine swap.
    expect($generator->generate())->toBe('PASTE_ACTUAL_OUTPUT_HERE');
});

I use the two-seed assertions as the default and the locked literal only where the exact output is part of the contract. The mental model is the same one behind freezing the clock — you replace an ambient global with an injected dependency, which is exactly why I switched to immutable, injectable dates with CarbonImmutable.

Gotchas and Edge Cases#

String seeds have exact lengths. Xoshiro256StarStar wants precisely 32 bytes and PcgOneseq128XslRr64 wants 16. Passing hash('sha256', $seed) without binary: true gives you 64 hex characters and a ValueError. A 32-byte all-NUL seed is also rejected.

Bind per resolve, not as a singleton. A seeded engine advances its internal state on every call. Share one Randomizer singleton across a Pest suite and test three gets whatever position tests one and two left behind — order-dependent failures that vanish when you run the file alone. bind() hands each resolve a freshly seeded engine.

getFloat(), nextFloat() and getBytesFromString() are PHP 8.3+. The class itself landed in 8.2. If a snippet elsewhere on the internet uses getFloat() on 8.2, that is why it fails.

No install step, but Secure can still throw. ext-random is compiled into PHP from 8.2 and cannot be disabled, so there is nothing to add to composer.json. Random\Engine\Secure does throw Random\RandomException on a platform with no usable CSPRNG source — rare, but it means token generation is a throwing operation and should be treated as one.

shuffleBytes() shuffles bytes, not characters. Feed it a UTF-8 string with any multi-byte character and you get mojibake. For text, split to an array of characters and use shuffleArray().

Do not "fix" Str::random(). Laravel's Str::random() is built on random_bytes() and is already cryptographically secure. Same for Str::uuid() and key:generate. The functions worth hunting down are rand(), mt_rand(), str_shuffle(), array_rand() and uniqid() — and a Pest security() preset will find them for you, which is one of the rules I cover in enforcing architecture with Pest's arch() helper. The global functions themselves are not deprecated, so nothing in the PHP 8.5 deprecations cheat sheet will flag them. That check has to be yours.

Wrapping Up#

Default to new Randomizer() and let it use Secure. Bind Randomizer::class in your service provider with bind(), swap in a seeded Xoshiro256StarStar for the testing environment, and inject it everywhere you currently call random_int() or Str::random() directly. Then add a security() arch test so the old functions cannot creep back in.

If you want the locked-literal approach without hand-maintaining the strings, Pest 4 snapshot testing handles the update-on-purpose workflow properly. And if you are auditing a codebase for this class of bug, Pest's arch() helper is the fastest way to get a complete list.

FAQ#

What is Random\Randomizer in PHP?

Random\Randomizer is the object-oriented API to randomness added in PHP 8.2. It is a final class that provides the operations — getInt(), getBytes(), shuffleArray(), pickArrayKeys() and friends — while delegating the actual random numbers to a Random\Engine implementation you pass to its constructor. Construct it with no arguments and it uses Random\Engine\Secure, which is the same cryptographically secure source as random_bytes().

Which PHP random engine should I use for security tokens?

Random\Engine\Secure, which is also the default when you write new Randomizer(). It is the only bundled engine that implements Random\CryptoSafeEngine and the only one that reads from the operating system's CSPRNG rather than expanding a seed. None of Mt19937, Xoshiro256StarStar or PcgOneseq128XslRr64 is acceptable for tokens, password resets, invite codes or anything else a user could try to predict.

Is Mt19937 safe for generating tokens?

No. Mersenne Twister is not a cryptographic generator: its internal state can be reconstructed by observing a modest run of consecutive outputs, after which every future value is predictable. On top of that, its seed is a 32-bit integer, giving only about 4.3 billion possible sequences and a realistic chance of accidental collisions after roughly 77,000 seeds. Use Mt19937 only when you need to reproduce output from legacy mt_rand() code.

How do I make random values reproducible in tests?

Stop calling global random functions and inject a Random\Randomizer instead. Bind Randomizer::class in your service provider so the testing environment gets a seeded engine — new Xoshiro256StarStar(hash('sha256', 'test-seed', binary: true)) — and production gets new Secure(). Use bind() rather than singleton(), otherwise the engine's advancing state leaks between test cases and you swap flakiness for order dependence.

What is the difference between random_int() and Randomizer?

random_int() is a global function permanently wired to the OS CSPRNG, so it is secure but impossible to make deterministic. Randomizer::getInt() does the same job by default, because the default engine is Secure, but the source of randomness is a constructor argument you control. That single difference is what makes randomness an injectable, swappable dependency rather than an ambient global.

Is Str::random() in Laravel cryptographically secure?

Yes. Str::random() is implemented on top of PHP's random_bytes(), so it draws from the same CSPRNG as Random\Engine\Secure and is safe for tokens and secrets. There is no security reason to replace working Str::random() calls. The only reason to inject a Randomizer instead is testability — you cannot seed Str::random(), so any test that depends on its exact output has to assert loosely.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.