edulazaro / lararand
Randomness for Laravel with the source as a config line: quantum vacuum noise measured at the ANU, atmospheric noise from random.org, or the machine's own CSPRNG. Chain them and a provider being down, rate limited or out of quota never costs you a draw. Unbiased integers, shuffles and draws without
Requires
- php: >=8.2
- laravel/framework: >=12.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
README
Lararand
Randomness for Laravel with a source you choose. Draw, shuffle and deal through one API, and decide separately where the entropy comes from: the machine's CSPRNG, a quantum generator measuring vacuum fluctuations, atmospheric noise. Swapping one for another is a config line, and nothing that deals a hand ever learns which it got.
use EduLazaro\Lararand\Facades\Rand; Rand::distinct(6, 49); // six lottery numbers, no repeats Rand::shuffle($deck); // a real shuffle, every permutation equally likely Rand::pick($prizes, 3); // three winners Rand::int(1, 20); // a d20 Rand::string(12); // a code
Why not just random_int
Most of the time you should. random_int is the system CSPRNG, it is what every session
id on the box already rests on, it costs nothing and it cannot be down. This package
exists for the case where the origin of the randomness is part of what you are selling:
a draw someone could contest, a shuffle a regulator asks about, a product whose whole
claim is that nobody arranged the outcome. "Our lottery runs on quantum vacuum noise" is
a sentence you can put in front of a customer. "We called random_int" is not, even
though it is a fine answer.
And once you have written that sentence you need the rest of this: a remote generator will be down, rate limited or slow, and a draw that fails in front of a customer is worse than one drawn locally.
Install
composer require edulazaro/lararand php artisan vendor:publish --tag=lararand-config
Out of the box it uses the system CSPRNG and nothing else happens. To go further, list the sources you want:
// config/lararand.php 'chain' => ['anu_public', 'system'],
anu_public is the Australian National University's free endpoint and needs no account at
all, so that one line is the whole setup.
Pin ['system'] in your test suite. An external generator there spends quota on nothing
and makes the suite fail on somebody else's bad day rather than on your bugs.
Sources
| Key | Where it comes from | Needs |
|---|---|---|
system |
The machine's CSPRNG, via random_bytes |
nothing |
anu_public |
The same ANU vacuum noise, free endpoint, 1 req/min | nothing |
anu_quantum |
Vacuum fluctuations, measured at the ANU | an API key |
random_org |
Atmospheric noise, from random.org | an API key |
A quantum generator is unpredictable because there is nothing to invert: the field jitters whether or not anyone is measuring it. A CSPRNG is unpredictable because inverting it is expensive. That distinction rarely changes a threat model, and it changes what you can say.
Adding a provider is a class implementing Contracts\RandomSource and an entry in the
config. One method, bytes(int $count): string.
Sources/ holds exactly what you can name in chain, and nothing else. Buffer and
Fallback live in Decorators/ because they produce no randomness of their own — they
are RandomSource by type and by no other measure, and filing them beside the real ones
would make a directory of five files look like five things to choose between.
One contract, and it is bytes
Everything else is derived by the package: integers in a range, draws without replacement, shuffles. Not to keep drivers small, but because the derivation is where the bug lives.
$byte % 78 looks like it gives a card from a deck of 78, and it does, but not evenly.
256 is not a multiple of 78, so the first 22 cards come up four times per 256 bytes and
the rest three: a 33% edge on a third of the deck, forever. Nothing throws. Any test that
checks the range passes. It only shows up if you count.
So integers go through rejection sampling, once, in one place, instead of in every driver that would have had to rediscover it. The test for that feeds every byte value 0-255 through and asserts each of the 78 outcomes appears exactly three times.
Shuffles are Fisher-Yates walking down and swapping with a position at or below, not the tempting version that swaps with any position at all: that one spreads n^n paths over n! outcomes, and the two do not divide.
The chain
chain is a list, tried left to right: the first source that answers serves the bytes.
'chain' => ['anu_public', 'anu_quantum', 'random_org', 'system'],
A literal list and not a comma-separated environment variable: lists belong in the config file, and parsing one out of an env string is a trap for a value nobody types twice — a semicolon instead of a comma gives you an invented source name and a runtime error.
The provider folds it, gives each source its own cooldown and puts one buffer over the whole thing:
Buffer( Fallback( Cooldown(AnuPublicSource),
Fallback( Cooldown(AnuQuantumSource),
Fallback( Cooldown(RandomOrgSource), SystemSource ))))
A list rather than one source plus a fallback flag, because a flag is a boolean that
secretly names a source: it says "yes" and the code decides the backup is the CSPRNG.
Here the backup is written down, and there can be more than one.
The last entry is the one with nothing underneath it. Put system there and a draw
can never fail; leave it out and a draw fails loudly when the provider does. Do that where
the origin is a guarantee you have made rather than a preference — a public lottery, an
audited shuffle — because there an error is more honest than a quiet substitution.
Buffered because the arithmetic asks for a byte at a time, and dealing seven cards unbuffered is eight HTTP requests. A kilobyte block makes it one, and the next few hundred draws free. These providers meter calls, not bytes.
The buffer goes outside the chain, and the order is not a detail. Inside, the thing that fills the buffer is the thing that can fail, so a provider that is down means the buffer never fills and every scrap of entropy goes back out to the network: seven cards become eight chained timeouts. Outside, the block is asked for once, the chain settles it whoever is down, and a failed provider is not retried until the block runs out — a circuit breaker for free.
Watch the log. Every fall is a warning on your default channel. This matters more
than it looks: a fallback nobody watches means believing you run on a quantum generator
while the free tier ran out in March. Silence looks exactly like success here.
Cooldown
A source that has just refused is skipped for a while rather than asked again on every buffer refill. Because a rate limit is not an outage: the ANU public endpoint allows one request a minute and answers the second one instantly, so without this every refill spends a round trip rediscovering that, and on a metered provider it spends quota rediscovering that the quota is gone.
Set it to roughly the window the provider measures, divided by how many probes you are willing to spend on finding out it has recovered:
| Source | Its limit | Cooldown | Why |
|---|---|---|---|
anu_public |
1 request / minute | 2 min | The window is a minute, and probing is free |
anu_quantum |
100 / month (free) | 10 days | A probe is 1% of the month; 10 days is 3 |
random_org |
1,000 / day | 1 hour | 24 of 1,000 a day, to recover the same day |
The state lives in your application's default cache and is SHARED, so one worker learning that the quota is gone spares every other worker from finding out too.
It fails open: a cache that is down must not make randomness unavailable, or a site stops dealing cards because memcached is restarting.
API
Rand::bytes(32); // raw bytes Rand::below(78); // 0 to 77 Rand::int(1, 6); // 1 to 6, both included Rand::ints(5, 1, 6); // five dice, repeats allowed Rand::distinct(6, 49); // six of 49, no repeats, random order Rand::shuffle($items); // a permutation; keys are dropped Rand::pick($items, 3); // three items, no item twice Rand::one($items); // one item Rand::float(); // 0.0 to 1.0, exclusive at the top Rand::string(12, 'ABCDEF0123456789');
Inject EduLazaro\Lararand\Randomness instead of using the facade wherever you would
rather say what you depend on.
distinct() switches strategy on the ratio it is given. Asking for a sliver of a wide
range draws and discards repeats; asking for most of the range shuffles the range instead,
because the last few values of 78 out of 78 need some 78 draws each and rejection sampling
would spend 390 draws where a shuffle spends 78.
Testing
Point the config at system and it never leaves the machine. To assert exact numbers,
inject a source that hands out bytes you chose:
use EduLazaro\Lararand\Contracts\RandomSource; use EduLazaro\Lararand\Randomness; $scripted = new class implements RandomSource { public function bytes(int $count): string { return str_repeat("\x00", $count); } }; $this->app->instance(Randomness::class, new Randomness($scripted));
Randomness is the one thing you cannot test by looking at the output. Take the randomness away and assert the arithmetic.
Not in scope
Signed draws. random.org can sign a result so a third party can verify it was not altered, which is the right tool for a public lottery, and doing it properly means storing signatures and offering verification rather than just calling a different endpoint. If you need that today, call their API directly.
License
MIT. See LICENSE.md.
