owenvoke/mnemonics-php
A lightweight PHP library for generating mnemonics, helping you turn data into memorable, human-friendly phrases. Simple API, framework-agnostic, and easy to drop into any PHP or Laravel project.
1. Check your PHP baseline before anything else. The description.md says "PHP 7.4+", but the v3.0.0 release notes say PHP 8.3 or later — the description is stale. Trust the changelog. Check composer.json's "php" constraint, php -v, and your CI image (e.g. php:8.2-fpm will fail the install). If you're on 8.1/8.2, this is a platform-upgrade decision that happens to involve a 4-star utility; don't let this package drag your floor forward.
composer require owenvoke/mnemonics-php:^3.0
Pin ^3.0. Never *, never dev-main — one maintainer, four stars, zero dependents.
2. Read these three things in this order — nothing else:
src/Mnemonic.php — the only class you should ever call from application code. The single question that shapes your whole design: does it accept entropy, or does it call random_bytes() internally? If you can pass entropy in, this package degrades to a pure wordlist encoder and you own the RNG — the safest possible outcome. If it generates internally, read that call before this touches anything user-visible.src/BitArray.php — skim only, then never reference it again. The v3 changelog (next() retyped to void per Iterator) tells you it's an internal implementation detail, not a contract.3. First use case — five minutes in php artisan tinker, not in a controller:
$phrase = \Owenvoke\Mnemonic\Mnemonic::generate(6);
// => "canyon drift velvet anchor mule brisk"
Adjust the method name to whatever src/Mnemonic.php actually exposes. Once that returns a phrase, your remaining work is entirely about where the call lives and how many words, not whether it works.
The correct day-one Laravel use case is a low-stakes, human-readable random string: an invite code, a device pairing phrase, demo/seed data, or a support-friendly identifier. Do not start with account recovery for financial or health data — see the Gotchas section for why.
The v3.0.0 breaking changes are the concrete argument for this pattern: enforced type hints across all classes, BitArray::next() → void, Mnemonic::ENTROPY_BITS removed. A codebase that type-hinted BitArray or read ENTROPY_BITS took an unplanned hit on a minor-sounding upgrade. A codebase with one adapter file took none.
// app/Support/Passphrase/PassphraseGenerator.php
namespace App\Support\Passphrase;
use Owenvoke\Mnemonic\Mnemonic;
final class PassphraseGenerator
{
public function __construct(
private readonly int $words = 6,
private readonly string $separator = '-',
) {}
/** @return list<string> */
public function words(?int $count = null): array
{
return Mnemonic::generate($count ?? $this->words); // confirm signature in src/
}
public function generate(?int $count = null): string
{
return implode($this->separator, $this->words($count));
}
}
If Mnemonic accepts entropy, prefer that form and own the RNG:
$entropy = random_bytes(16); // you control the source
return implode($this->separator, Mnemonic::fromEntropy($entropy));
Bind it once so the Owenvoke\Mnemonic namespace appears in exactly one file in your codebase:
// app/Providers/AppServiceProvider.php (register())
$this->app->singleton(PassphraseGenerator::class, fn () => new PassphraseGenerator(
words: config('passphrase.words'),
separator: config('passphrase.separator'),
));
// config/passphrase.php
return [
'words' => (int) env('PASSPHRASE_WORDS', 6),
'separator' => env('PASSPHRASE_SEPARATOR', '-'),
];
Cast in config so PASSPHRASE_WORDS=8 (a string from .env) doesn't hit a TypeError under strict types.
use the package inside a controllerfinal class InviteCodeController extends Controller
{
public function __construct(private readonly PassphraseGenerator $phrases) {}
public function store(StoreInviteRequest $request): RedirectResponse
{
$code = $this->phrases->generate(count: 4);
Invite::create([
'code' => $code,
'code_hash' => Hash::make($code), // if it's actually a secret
'expires_at' => now()->addDays(7),
]);
return back()->with('invite_code', $code); // displayed exactly once
}
}
For API responses, prefer returning the phrase directly in the resource rather than persisting plaintext:
return response()->json(['code' => $this->phrases->generate(4)], 201);
At 11 bits per word (2048-word list), copy this into the PR description:
| Words | Entropy | Honest verdict |
|---|---|---|
| 4 | ~44 bits | Fine for invite codes / rate-limited pairing |
| 6 | ~66 bits | Reasonable for a displayed-once recovery phrase |
| 8 | ~88 bits | Approaching offline-attack resistance |
| 12 | ~132 bits | BIP-39 territory — only if the list and checksum are standard |
"Undocumented entropy" is the single most common review failure with this package. If you can't state the bit count in the PR, don't ship the feature.
// tests/Feature/InviteTest.php
$this->mock(PassphraseGenerator::class)
->shouldReceive('generate')
->once()
->andReturn('canyon-drift-velvet-anchor');
Then keep a separate unit test that exercises the real adapter and asserts shape only:
// tests/Unit/PassphraseGeneratorTest.php
$out = (new PassphraseGenerator(words: 6))->generate();
$this->assertCount(6, explode('-', $out));
$this->assertNotEmpty($out);
That second test is the tripwire: it catches an upstream wordlist, word-count, or separator change on the next composer update. It is also the test that fails loudly if a future major renames generate() — which, given v3's history, is plausible.
RefreshDatabase + Bus::fake() + mocked adapter means your feature suite never touches the real RNG. Good: deterministic tests, fast suite.
Hyphens and spaces are both mangled by hand transcription and by mobile keyboards inserting non-breaking spaces.
final class NormalizePassphrase
{
public static function apply(string $phrase): string
{
$phrase = str_replace("\u{00A0}", ' ', $phrase); // NBSP from mobile keyboards
return preg_replace('/\s+/', ' ', strtolower(trim($phrase))) ?? '';
}
}
Wrap it in a rule — ten lines, saves a support ticket a week:
final class ValidPassphrase implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$words = preg_split('/\s+/', NormalizePassphrase::apply((string) $value)) ?: [];
if (count($words) < 4) {
$fail('The :attribute must contain at least four words.');
}
}
}
// php artisan app:seed-demo-passphrases --count=200
public function handle(PassphraseGenerator $phrases): int
{
foreach (range(1, (int) $this->option('count')) as $_) {
$this->line($phrases->generate(6));
}
return self::SUCCESS;
}
Highest-value, lowest-risk use of the package: demo data and QA fixtures that don't look like x8F2kQa9Lm. It also lets you eyeball wordlist bias across a few hundred samples for free — useful if anyone in the review asks "is this list uniform?"
Mnemonic lets you pass random_bytes(), do it. If it generates internally, read the RNG call and confirm it's random_int/random_bytes-backed, not mt_rand.The PHP 8.3 floor is a hard gate, and the description lies about it. The package description says 7.4+, the v3.0.0 changelog says 8.3+. composer require fails cleanly on 8.2 — that's the good outcome. Don't discover it in CI on a Friday.
Don't touch the internals. No subclassing Mnemonic, no referencing Mnemonic::ENTROPY_BITS (removed in v3), no type-hinting BitArray in your own code. v3 changed type hints across all classes and altered a method signature for language-version reasons, not defect reasons — that pattern predicts recurring churn with no corresponding benefit. If you need some of the package's behavior, copy the logic into your adapter and delete the dependency.
Assume you may fork within two quarters. MIT, single-file surface, one maintainer, 4 stars, 0 dependents. If phrase generation lands on a critical path, vendor at a pinned tag (composer require owenvoke/mnemonics-php:3.0.0 and commit the lockfile). dev-main is not a strategy.
The registry dates are internally inconsistent. The v3.0.0 release note is dated 2026-03-30, but the "last release" metadata says 2026-10-19. Verify actual VCS tags (git ls-remote --tags) before pinning; don't trust a registry date when choosing a version.
Wordlist provenance is still your job. The release notes say nothing about the list. Confirm: how many words, is it BIP-39 English, where did it come from, what's its license. If you're writing a security review and can't name the list, you don't have a security review.
Never log the phrase. dd() in tinker, Log::info() debugging, Telescope, Sentry breadcrumbs, and exception context all capture payloads. If the phrase is a secret, it will end up in storage/logs/laravel.log and your error tracker. Return it directly in the response with no intermediate logging, and re-read config/logging.php before you ship. If you must debug, log the word count, never the words.
php artisan config:cache will silently ignore your .env changes. env('PASSPHRASE_WORDS') inside config/passphrase.php is correct; calling env() from a service provider or controller at request time is not (it returns null once cached). Standard Laravel rule, but this package's config surface is entirely yours, so it's easy to get wrong.
There is no facade, no service provider, no published config, no Blade directive. The package is deliberately framework-agnostic. Your extension points are: the adapter class, config/passphrase.php, and the word count. That's the whole surface. Wrapping a decorator around a decorator around Mnemonic is the failure mode here.
Rate-limit any endpoint that mints phrases. Not for entropy reasons — an unthrottled "generate me a code" route is a free database-write primitive and an enumeration surface. ThrottleRequests on the route, plus a per-user cap if codes are user-scoped.
Reject ambiguous submissions early. Users will submit with capitals, double spaces, and their phone's non-breaking space. Normalize on input (NormalizePassphrase above) and store the normalized form alongside whatever you display — otherwise canyon drift and Canyon Drift become two different credentials and one support ticket each.
Don't use it for high-value recovery. Wordlist bias, checksum correctness, constant-time comparison, RNG choice — none of these are settled by a package with one maintainer and zero dependents. If the phrase protects meaningful assets, treat this as a prototype that buys you a sprint to scope an audited, standards-compliant replacement. Say that explicitly in the design doc so the next developer doesn't assume it was a considered long-term choice.
Queue-safe, container-safe, but not config-safe. The generator has no I/O, no state, no static caches — it's fine in jobs and Octane workers. The one thing that isn't safe is reading config after config:cache in a long-running worker that was booted before a config change. Restart workers on deploy as usual.
Watch for upstream changes via your shape test, not via production. That assertCount(6, explode('-', $out)) unit test is worth more than any amount of changelog reading. Give it a comment linking to the v3.0.0 release notes so the next person understands why it exists.
How can I help you explore Laravel packages today?