phil / access-codes
Single-use codes in named batches: an alphabet without lookalike symbols, a check symbol, chosen length and entropy; codes given once at creation, as CSV with prefilled redemption links, stored as keyed hashes under an ordered list of keys; atomic redemption, expiry, batch revocation, counters, rate
Requires
- php: >=8.5
- ext-mbstring: *
- phil/url: ^0.1@dev
- psr/clock: ^1.0
- symfony/rate-limiter: ^8.2
Requires (Dev)
- doctrine/dbal: ^4.5
- phpunit/phpunit: ^13.4
- psr/log: ^3.0
- symfony/clock: ^8.2
- symfony/process: ^8.2
Suggests
- doctrine/dbal: To keep the batches and the code hashes in a database with DbalCodeStore
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 22:47:36 UTC
README
Single-use codes in named batches, given once at creation and kept only as keyed hashes, redeemed once.
phil/access-codes creates batches of codes — download cards sold at a booth, voucher codes printed in a book, keys
handed to backers — and redeems each code once. A batch has a name, a size up to a million, and an optional expiry.
Its codes are drawn by PHP's secure generator from an alphabet without lookalike symbols, Crockford's Base32 by
default, with a check symbol that catches a mistyped symbol before any lookup: 7KQ2-M9XD-H4TB-H. The length and the
entropy are chosen, never below 40 bits.
The codes in clear exist once: in the CreatedBatch that create() returns, written once as CSV — each code with its
prefilled redemption link — or taken once as a list for printing. The store keeps the HMAC-SHA-256 of each code under
the application's key, an ordered list of keys where the first hashes and every key finds, so that the key rotates
without losing a batch. Nothing can show a code again.
A redemption spends one attempt of a Symfony rate limiter, reads the code as typed — in lowercase, with hyphens or
spaces, O for 0 —, then redeems it in one conditional UPDATE: of two redemptions at the same instant, one wins.
The answer says whether the code was redeemed, and otherwise why: unknown, already redeemed, expired, revoked, or too
many attempts. A batch is revoked at once, for a box of cards lost, and counted: redeemed, revoked, unused.
It is a plain PHP library on phil/url, psr/clock and symfony/rate-limiter. The batches live in a store: in
memory for tests and scripts, or in two tables of a Doctrine DBAL connection on PostgreSQL, MySQL, MariaDB or SQLite,
tested on MySQL 8.4 and PostgreSQL 17. It holds no state between calls and runs in a worker. The redemption page and
the forms belong to the application or to a bundle; the library gives the page its prefilled links and the answer to
show.
Status
| Part | State |
|---|---|
| Alphabets without lookalikes, lookalike letters read as their digit, Luhn mod N check symbol, length and entropy chosen with a floor of 40 bits | Implemented and tested |
| Named batches of up to a million distinct codes from the secure generator, with an expiry | Implemented and tested |
| The codes given once: CSV with prefilled links, or a list; never serialized, cloned or dumped | Implemented and tested |
| HMAC-SHA-256 under an ordered list of up to eight keys: the first hashes, every key finds | Implemented and tested |
Redemption in one conditional UPDATE, outcomes, attempts bounded by a Symfony rate limiter |
Implemented and tested |
| Revocation of a batch, counters, redeemer named by a stable identifier | Implemented and tested |
| In-memory store; Doctrine DBAL store, its schema, eight processes racing on MySQL 8.4 and PostgreSQL 17 | Implemented and tested |
| Fuzz target of the code reader; benchmark of a batch of 100,000 codes | Implemented |
| A Symfony bundle: services from configuration, the redemption page and form, a console command that exports a batch | Not written |
Documentation
| Document | What it covers |
|---|---|
| Installation | Requirements, installing, building the services. |
| Codes | CodeAlphabet, CodeFormat: the alphabet, lookalikes, the check symbol, length and entropy. |
| Batches | Creating a batch, the one-time CSV and list, prefilled links, revocation, counters. |
| Redemption | redeem(), attempts, the outcomes, atomicity, the redeemer. |
| Keys and storage | CodeHasher and its ordered keys, the stores, the tables. |
| Exceptions | AccessCodesException and its four exceptions. |
| Extension points | The ports, arguments and data an application chooses or replaces. |
| Testing | The package's tests, its fuzz target and benchmark, and how to test code that redeems. |
| Reference | Every public type, with its role. |
Quick start
composer require phil/access-codes
use Phil\AccessCodes\AccessCodes; use Phil\AccessCodes\CodeHasher; use Phil\AccessCodes\InMemoryCodeStore; use Phil\AccessCodes\RedemptionLinks; use Phil\Url\Url; use Symfony\Component\Clock\NativeClock; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; $codes = new AccessCodes( new InMemoryCodeStore(), new CodeHasher($_SERVER['ACCESS_CODES_KEY']), new RateLimiterFactory(['id' => 'redeem', 'policy' => 'sliding_window', 'limit' => 10, 'interval' => '15 minutes'], new InMemoryStorage()), new NativeClock(), ); $created = $codes->create('Album cards, autumn fair', 500); $created->writeCsv(new SplFileObject('cards.csv', 'x'), new RedemptionLinks(Url::fromString('https://shop.example/redeem'))); $redemption = $codes->redeem('7kq2-m9xd-h4tb-h', 'member-1842', $clientAddress); $redemption->isRedeemed(); // true, and $redemption->batchId names what it unlocks
Requirements
PHP 8.5 or later with ext-mbstring, phil/url, psr/clock and symfony/rate-limiter 8.2 or later;
doctrine/dbal 4.5 or later for the database store.
Contributing
Contributions are welcome on the PHIL monorepo: read the contribution guide and the code of conduct. Report a vulnerability privately, as the security policy explains, never in a public issue.
License
MIT, © Philippe Gamache, doing business as Le Gnome Archiviste; a Parler Haut Interagir Librement project. See LICENSE.