What this is
PasswordToolkit generates passwords a person can read out loud: two words and a number, drawn from 201 curated dictionaries, with correct grammatical agreement in every language it speaks.
xK#9$!qZ
- 52 bits — genuinely stronger
- Unreadable down a phone line
- Mistyped, then reset, then mistyped
Fearless-Luke-Skywalker-481902
- 37 bits — and it says so, in the report
- Dictated once, spelled by nobody
Luke-Skywalker-Impavido-481902in Italian
It is one job, done carefully: the temporary password you send somebody, that they have to type back. Everything in the package serves that — the dictionaries are picked for names people recognise and can spell, the separator and digits are tuned for dictation, and the strength report tells you exactly what the legibility cost, in bits, without flattering the number.
Use cases
Four places where a password has to get through a person before it reaches a login form. The defaults are tuned for exactly these, and each one is worked through properly in Recipes.
- 01Onboarding
Create the account, store the hash, mail the plaintext once.
generateWithReport()hands you both at once, so the welcome email can say how long the password is safe to keep. - 02Support resets
Your agent has somebody on the line and one chance to be understood. Two dictionary words survive a bad connection, a strong accent and a noisy room; eight random characters do not.
- 03Printed handover
Workshop laptops, kiosk logins, a seeded demo tenant.
generateUnique()fills an entire roster in a single pass, with no repeats to explain away. - 04Share links
The password for a shared document, sent through a different channel from the link itself. Short enough to paste into a chat message, legible enough to survive being forwarded twice.
Do you need it?
Two questions decide it, and the second one catches more people than the first.
- Does a human have to read, type or dictate this password?
If no machine-to-machine secret is involved, keep reading. If one is, stop here —
Str::password(32)is shorter, stronger and costs you nothing to use. - Will it be replaced shortly after it is first used?
This is the one to be honest about. These passwords are built to be handed over, not to be kept. If nothing forces a change, you are storing a weak password indefinitely and no setting in this package fixes that.
- A credential you are about to put in an email
- Anything a support agent will say out loud
- Demo, seed and workshop accounts
- Anything followed by a forced change
Str::password()
- API keys, tokens and signing secrets
- Anything only a machine will ever read
- A password that will still be live next year
- Anything with no forced change behind it
numbers_digits moves that — twelve digits reaches roughly 57 — but it will never match a random string of the same length, and strength() will not pretend otherwise. You are paying entropy for the ability to say it out loud.Requirements
- PHP 8.2 or newer
- Laravel 10, 11, 12 or 13
Install the package
composer require gabrielesbaiz/password-toolkit
That is the whole install. The service provider is auto-discovered, so there is nothing to register: no migrations to run, no tables, no assets to publish, and no configuration required before the first call works.
Check it landed:
php artisan password-toolkit:generate 3
Three passwords means you are done. If that command is not found, clear the cached package manifest with php artisan package:discover and try again.
Publish the config
Optional, and worth skipping until you actually want to change something — the defaults are chosen to work untouched.
php artisan vendor:publish --tag="password-toolkit-config"
That writes config/password-toolkit.php: twenty-seven settings, each with a comment explaining what it costs you rather than just what it does. Configuration walks through them.
The strength labels and validation messages can be published too, if you want to reword them or add a language:
php artisan vendor:publish --tag="password-toolkit-translations"
Generate one
Nothing to configure first. The facade resolves a singleton, and the first call reads the dictionaries your settings select — only those — and caches them.
use Gabrielesbaiz\PasswordToolkit\Facades\PasswordToolkit; PasswordToolkit::generate(); // "Fearless-Luke-Skywalker-481902"
Two words and six digits, drawn with random_int(). The language follows your application locale unless you say otherwise, and word order follows the language.
Generate many
One dictionary scan for the whole batch, whatever the count — do not loop generate().
PasswordToolkit::generateMany(10); // ["Fearless-Luke-Skywalker-481902", "Aromatic-Barolo-337415", ...] - exactly 10 PasswordToolkit::generateUnique(10); // the same, guaranteed distinct
generateMany() returns exactly what you asked for and can repeat itself; generateUnique() will not repeat, and throws rather than under-deliver if the pool is too narrow to fill the request.
Generate with the report
When you are about to email the password, get its strength in the same call and put the honest number in front of whoever receives it.
['password' => $plain, 'report' => $report] = PasswordToolkit::generateWithReport(); $report->entropyBits; // 36.7 $report->displayLabel(); // "Fair" $report->crackTimeHuman; // "11 seconds"
This is the structural model — how many passwords this package could have produced — not the flattering charset figure a generic meter would show.
Override one call
Change anything for a single call without touching your config. The builder is immutable, so one half-configured instance is safe to keep on a property and reuse.
PasswordToolkit::make()
->locale('en')
->only(['star_wars', 'italian_wines'])
->digits(10)
->generate();
// "Fearless-Luke-Skywalker-4819025517"
Every option is there — see All methods for the full list.
Inject it instead
If you would rather not reach for the facade. The contract is bound in the container, so it is genuinely swappable in a test rather than a static call wearing a facade's clothes.
use Gabrielesbaiz\PasswordToolkit\Contracts\PasswordGenerator;
public function __construct(private readonly PasswordGenerator $passwords) {}
Everything else
That is the whole surface you need for the common case. When you want the rest — batch reporting, inspecting which dictionaries resolve, scoring a password a user chose, the full builder — All methods lists every public call with its signature and return type.
Try every command
This is the real password-toolkit:generate command, running the real dictionaries, in your browser. Every flag works. Nothing is sent anywhere — the dictionary data ships with the page and the generation happens in front of you.
↑ ↓ history · Enter run · help for the full flag reference · clear to wipe
How it differs from your app
The page ships a sample of the dictionaries, not all of them, so a pool figure here will be smaller than the one you get in your application. Everything else — the flags, the word order, the agreement, the maths — behaves exactly as the package does.
Choosing dictionaries
Everything lives in config/password-toolkit.php, and none of it is required — the defaults generate working passwords untouched. Start here, because which names are in play changes how the passwords sound more than any other setting.
'dictionaries' => [
'enabled' => '*', // '*', or ['star_wars', 'italian_wines']
'except' => [], // applied after 'enabled'
'types' => [], // ['people'] or ['things']; empty means both
'groups' => [], // thematic buckets: ['food', 'drink']
'tags' => [], // must carry EVERY tag listed
'reach' => null, // 'global' | 'italian' | 'niche'
'paths' => [], // directories of your own JSON files
'custom' => [], // dictionaries defined inline, key => [type, values]
],
groups, tags and reach narrow the pool thematically: reach is how far a name travels, so global keeps the ones a stranger would recognise and niche allows the rest. Filters combine, and a dictionary must satisfy all of them.
password-toolkit:generate --list shows exactly what resolves, which is faster than reasoning about the three keys above.
enabled is the cheapest setting there is. Since 2.0.1 a dictionary is decoded only once something selects it, so a list of keys opens those files and no others — one dictionary costs about 4 ms and 0.14 MB against roughly 18 ms for the full shelf. types is answered by the directory a dictionary sits in, so it costs nothing either; groups, tags and reach live inside the files, so '*' combined with those still has to read each one to know what it holds.Locale
'locale' => null, // null follows the application locale 'fallback_locale' => 'en', // used when a locale has no resources of its own
Sets which language the adjectives come from. Names carry a language of their own — see Translated names.
Separator
'separator_symbol' => '-', // any string, or null for none 'name_separator' => true, // "Luke-Skywalker" (true) or "LukeSkywalker" (false)
The separator goes between every part. name_separator decides whether it also goes inside a name that has a space in it.
Words
'word_count' => 2, // 2 or 3 'case' => 'title', // title | lower | upper | preserve
Three words means a second adjective, drawn without replacement and agreeing with the name in Italian. It buys four to eight bits — two digits' worth — and is easier to say than two more digits.
Casing is worth nothing in entropy. lower is the kindest to read aloud; preserve leaves both words exactly as the dictionary wrote them.
Word order
'adjective_position' => null, // null follows the locale; 'before' | 'after'
Numbers
'add_numbers' => true, 'numbers_digits' => 6, 'numbers_position' => 'end', // start | middle | end 'numbers_allow_leading_zero' => false,
With leading zeros off, 042 can never be drawn and six digits are 900,000 values rather than a million — about a sixth of a bit. The structural report accounts for this in both directions, so the figure you read is the space that actually exists.
Digits come from random_int(), and they are the setting that matters most. The word pools are fixed by the data that ships, so the digits are where the entropy is: each one adds 3.32 bits. Adding dictionaries is a poor lever by comparison — doubling every one of them buys a single bit.
| numbers_digits | structural bits | offline crack, 10^10 guesses/sec |
|---|---|---|
| 4 | 30.1 | 0.1 seconds |
| 6 (default) | 36.7 | 11 seconds |
| 10 | 50.0 | 31 hours |
| 12 | 56.7 | 133 days |
| 14 | 63.3 | 36 years |
| 18 (max) | 76.6 | 3,600 centuries |
For a password a human retypes, twelve digits is usually past memorable. If you need more than that you want a random string, not this package.
Leetspeak
'leetspeak_conversion' => 'none', // none | basic | advanced
basic substitutes twelve single characters and preserves length — Skywalker becomes $kyw41k32. advanced uses the whole table including multi-character glyphs, which lengthens the password and is useful against a strict minimum-length policy.
| char | basic | advanced |
|---|---|---|
| a / e / i / l / o | 4 3 1 1 0 | same |
| b / g / q / r / t / z | 8 9 9 2 7 2 | same |
| s | $ | $ |
| c / h / x | — | < # % |
| m / n / w | — | |V| |\| \/\/ |
Strength assumptions
'strength' => [
'guesses_per_second' => 1e10, // one offline GPU against a fast hash
// The bands every score is read against. They must ascend.
'thresholds' => ['weak' => 28, 'fair' => 36, 'strong' => 60, 'very_strong' => 128],
// Which model the StrongPassword rule scores with.
'rule_model' => 'charset', // charset | structural
],
Raise guesses_per_second towards 1e12 if your threat model includes a well-funded adversary; every reported crack time moves with it. The thresholds decide what StrongPassword accepts at signup, so raising strong raises your signup bar.
Batch generation
'unique_attempts_multiplier' => 10,
How hard generateUnique() tries before admitting the pool is too narrow: it makes count × 10 + 100 attempts. Raise it if you are drawing many passwords from one small dictionary.
Configuration builder
Set what you need and copy the result. The file below contains only the keys that differ from the defaults — everything you leave alone stays out of it, because a config file full of restated defaults is a file nobody can read a year later.
The whole shelf
Two hundred and one dictionaries, 4,411 names. You will use a handful — the ones your users would recognise — and the filters exist so you can find them without reading the rest. Everything here is the real metadata: the same groups, tags and reach values password-toolkit:generate --list prints.
Reading the metadata
Every dictionary carries four things worth filtering on, and all four are available in code as well as on this page.
| Field | Values | What it is for |
|---|---|---|
group | twelve themes | The broad subject. One per dictionary. |
type | people / things | Whether the names are people. Italian adjectives agree either way. |
reach | global / italian / niche | How far a name travels. Global ones a stranger would recognise. |
tags | 114 of them | Free-form and cumulative: a dictionary must carry every tag you ask for. |
locale | en / it | The language the names are written in, which is not the same as your app locale. |
PasswordToolkit::make()->groups('food')->reach('global')->generate();
PasswordToolkit::make()->tagged(['italian', 'sweet'])->generate();
PasswordToolkit::dictionaries(); // every one in play, with this metadata
PasswordToolkit::dictionariesWithSamples(); // the same, plus example entries
A directory of JSON files
The usual route, and the one that survives upgrades. Point the package at a directory and every .json file in it becomes a dictionary.
'dictionaries' => [
'paths' => [resource_path('password-dictionaries')],
],
Scaffold one rather than remembering the shape:
php artisan password-toolkit:make-dictionary my_team --type=people
That writes a working stub you edit in place:
{
"key": "my_team",
"type": "people",
"values": [
{ "name": "Example One", "gender": "neutral" },
{ "name": "Example Two", "gender": "neutral" }
]
}
| Flag | What it does |
|---|---|
--type= | people or things. Defaults to things. |
--path= | Where to write it. Defaults to your first configured path. |
--locale= | Also scaffolds an adjective file for that locale, beside it. |
--force | Overwrite a file that is already there. |
Inline in config
When there are only a handful and they never change. A bare list of strings works — gender defaults to neutral.
'dictionaries' => [
'custom' => [
'company_products' => [
'type' => 'things',
'values' => ['Orbit', 'Beacon', 'Lantern'],
],
],
],
At runtime
From a service provider, when the data lives somewhere else entirely.
PasswordToolkit::registerDictionary(
key: 'team_nicknames',
values: User::pluck('nickname')->all(),
type: 'people',
locale: 'en',
);
flushCache() if you register after the first generation. Dictionaries are read once and cached; a late registration is otherwise invisible until the next request.Making it filterable
A dictionary needs only key and values. name is what a picker shows — without it the key is made readable, so company_products becomes “Company Products”. Everything else is optional metadata — and it is what lets your dictionary be selected the same way the built-in ones are, by group, tag or reach.
{
"key": "company_products",
"name": "Our Products",
"type": "things",
"locale": "en",
"group": "culture",
"tags": ["internal", "products"],
"icon": "🛰️",
"reach": "niche",
"values": [
{ "name": "Orbit", "gender": "neutral" }
]
}
reach is worth setting honestly: your product names are niche, and marking them global only means they survive a filter that was meant to exclude them. Group must be one of the twelve; tags are free-form. See All dictionaries for what the built-ins use.
Adjectives
You never have to supply adjectives. A dictionary without them falls back to the locale's default pool, so the smallest useful personal collection really is one file of names. When you do want your own, the layout under your configured path is:
| File | Holds |
|---|---|
{path}/{key}.json | The names. |
{path}/{locale}/{key}.json | Adjectives for that locale. |
{path}/names/{locale}/{key}.json | Name translations for that locale. |
names/ segment. Translations sit one level deeper than adjectives on purpose — without it, {path}/it/my_team.json would have to be two different files at once.What the package guarantees
[A-Za-z0-9_][A-Za-z0-9_-]* and locales must look like en, it or pt_BR. A value that does not match is rejected rather than followed — which matters if a key ever comes from a request.How an adjective is chosen
Adjectives live in src/Data/Adjectives/{locale}/ and resolve in this order — first pool with an agreeing word wins:
{locale}/{dictionary}.jsonthemed: Italian adjectives written for Star Wars{locale}/_default.jsonthe locale's general pool{fallback_locale}/{dictionary}.json{fallback_locale}/_default.json
English is the reference locale. A locale with no resources of its own falls back to English, not Italian, because English is the language most likely to be understood by somebody who did not get the locale they asked for. A French or German application gets English adjectives and English names, and only the entries that are genuinely Italian stay Italian.
Agreement and word order
Italian adjectives agree with the gender of the name, so the pool is filtered before anything is drawn. English ones are all neutral — English adjectives do not inflect, so every one is eligible for every name.
Word order comes from the language too: Italian puts the adjective after the noun, English before it.
PasswordToolkit::make()->locale('it')->only('rock_bands_70s')->generate();
// "Fleetwood-Mac-Tonante" <- thundering
PasswordToolkit::make()->locale('it')->only('rock_bands_2020s')->generate();
// "Sleep-Token-Insolente" <- insolent
Every dictionary has a themed pack in both languages, kept deliberately small so it stays sharp rather than dissolving into general vocabulary: English packs run 13 to 20 entries. Italian packs look larger — up to 38 — because an -o/-a adjective ships both forms as separate entries. The number of distinct words is the same.
Two adjectives
With word_count set to three, two adjectives are drawn from the same pool, both agreeing with the name.
PasswordToolkit::make()->words(3)->locale('it')->only('rock_bands_70s')->generate();
// "Led-Zeppelin-Maestoso-Tonante"
They are distinct by word, not by array position — two entries spelling the same adjective would produce Mitico-Mitico, which reads as a mistake rather than a phrase. That is also what makes the reported entropy true of the draw: the pair is worth log2(A) + log2(A−1), not 2 × log2(A).
The default pool
A dictionary without a themed pack falls back to _default: 307 adjectives in Italian, 224 in English, chosen to suit a person, a place or a thing equally.
It is deliberately free of domain-bound vocabulary. A general pool carrying culinary words produces Magic-Johnson-Corposo — a basketball player described as full-bodied — and a test now prevents exactly that.
Adding a language
Drop one file at {yourpath}/{locale}/_default.json declaring its word order, and it works everywhere immediately. Themed files per dictionary are optional and can follow later.
{
"key": "_default",
"locale": "en",
"adjective_position": "before",
"values": [{ "name": "Legendary", "gender": "neutral" }]
}
adjective_position is read from _default.json only — it is one fact about the language, not something a themed pack restates. A locale that declares nothing is treated as putting the adjective after the noun.
How the English packs are made
Neither language is derived from the other at runtime: both are first-class data. But the English packs are generated, from the Italian ones plus a single reviewable glossary.
php build/build-adjectives.php
src/Data/Adjectives/_glossary.it-en.json records the correspondence, keyed by Italian lemma. Correct a word there and re-run the build; never hand-edit a generated English file — a test compares them against the glossary and will catch it. A brand new locale should be translated from the English packs rather than the Italian, since English is the reference.
Why each dictionary declares a language
Adjectives belong to the locale you ask for. Names do not. Every dictionary declares the language its own names are written in, because there is no single right answer across two hundred of them.
A dictionary about Italian wines is Italian in every locale — Barolo is Barolo, and so is every pasta shape, cyclist and volcano. There is nothing to translate, and pretending otherwise would be worse than leaving it alone. A dictionary about Harry Potter is English, and Italian is the dub.
The split across what ships today: 119 dictionaries are English-source, 81 are Italian-source. Of those, 40 carry a translation overlay covering 376 names — 34 into Italian, 6 into English. The rest need none.
| Dictionary | Source | Italian | English |
|---|---|---|---|
harry_potter | en | Albus Silente | Albus Dumbledore |
disney_characters | en | Topolino | Mickey Mouse |
roman_emperors | en | Marco Aurelio | Marcus Aurelius |
italian_monuments | it | Galleria degli Uffizi | Uffizi Gallery |
italian_wines | it | Barolo | none, and none wanted |
Bold is the form the dictionary actually stores; the other column is an overlay on top of it.
How a name resolves
A dictionary already written in the language you asked for is returned untouched — no file is consulted, and none should exist. Otherwise the lookup runs {locale} → {fallback_locale} → the stored name.
PasswordToolkit::make()->locale('fr')->only('italian_monuments')->generate();
// "Historic-Uffizi-Gallery-155689" <- no French overlay, so the English one
PasswordToolkit::make()->locale('de')->only('italian_wines')->generate();
// "Mineral-Malvasia-692534" <- nothing to translate, in any language
So a locale you have never heard of still produces sensible output: English names where an English overlay exists, and the untouched original everywhere else.
Writing an overlay
A translation file is a plain map, sparse on purpose — list only what differs. Anything absent keeps the name the base file stores, so an overlay does not have to be maintained in lockstep with the dictionary it covers.
{
"key": "harry_potter",
"locale": "it",
"values": { "Albus Dumbledore": "Albus Silente" }
}
Topolino is masculine because Mickey Mouse is marked masculine in the base file — so an Italian adjective agrees correctly even though the name it agrees with came from an overlay.For the package's own dictionaries these live at src/Data/Names/{locale}/{key}.json. For your own, the same file goes under {yourpath}/names/{locale}/{key}.json.
Generating
Every one of these takes an optional Options as its last argument. Pass nothing and it reads your config; pass one and it ignores your config entirely.
| Method | Returns | What it does |
|---|---|---|
generate() | string | One password. Throws rather than returning null when no dictionary resolves. |
generateMany(int $count) | array | Exactly $count passwords, one scan. Repeats are possible. |
generateUnique(int $count) | array | The same, guaranteed distinct. Throws rather than under-delivering. |
generateWithReport() | array | ['password' => string, 'report' => StrengthReport], structural model. |
generateManyWithReport(int $count) | array | A list of those pairs. |
make() | PasswordBuilder | An immutable builder seeded from your config. |
The builder
Each method returns a new builder, so one half-configured instance is safe to hold and reuse. Enums and their string spellings are interchangeable throughout.
| Method | Accepts | Effect |
|---|---|---|
only() / except() | array|string | Include or exclude dictionaries by key. |
types() | array|string | people, things, or both. |
groups() | array|DictionaryGroup|string | Filter by thematic group. |
tagged() | array|string | Must carry every tag given. |
reach() | Reach|string|null | Minimum recognisability: global, italian, niche. |
paths() | array|string | Extra directories of your own dictionaries. |
locale() / fallbackLocale() | ?string | Adjective language, and what an unknown locale falls back to. |
separator() | ?string | Any string, or null for none. |
keepWordBreaks() | bool | Luke-Skywalker or LukeSkywalker. |
words() | int | Two words or three. |
casing() | Casing|string | title, lower, upper or preserve. Worth zero bits. |
digits() / withoutNumbers() | int | 1 to 18, or none at all. |
allowLeadingZero() | bool | Whether 042 is a possible draw. |
numbersAt() | NumbersPosition|string | start, middle or end. |
adjectiveAt() | AdjectivePosition|string|null | Override the locale's word order. Leave it null. |
leet() | Leetspeak|string | none, basic or advanced. |
guessesPerSecond() | float | The attacker the report assumes. |
options() | Options | The resolved DTO, if you want to inspect or store it. |
Terminate with generate() → string, or many() / unique() / withReport() / manyWithReport() → array.
use Gabrielesbaiz\PasswordToolkit\Enums\Casing;
use Gabrielesbaiz\PasswordToolkit\Enums\Leetspeak;
use Gabrielesbaiz\PasswordToolkit\Enums\NumbersPosition;
PasswordToolkit::make()
->locale('en')
->groups(['screen', 'myth'])
->reach('global')
->words(3)
->casing(Casing::Lower)
->digits(10)
->numbersAt(NumbersPosition::Middle)
->leet(Leetspeak::Basic)
->many(5);
->leet('basic') and ->leet(Leetspeak::Basic) are the same call.Inspecting what resolves
| Method | Returns | What it does |
|---|---|---|
dictionaries() | Collection | Every dictionary in play, with key, label, group, tags, reach and count. |
dictionariesWithSamples() | Collection | The same, plus example entries — what a picker UI wants. |
groups() / tags() | Collection | The groups and tags actually present, with counts. |
poolSizes() | array | ['names' => int, 'adjectives' => int]. |
registerDictionary(...) | void | Add one at runtime, from a service provider. |
flushCache() | void | Forget the decoded JSON. Needed after changing config at runtime. |
clearPoolCache() | void | Deprecated alias for flushCache(). Removed in 3.0. |
Scoring an existing password
PasswordToolkit::strength($password); // charset model PasswordToolkit::structuralReport($password); // structural model
Both return a StrengthReport:
| Property | Type | |
|---|---|---|
entropyBits | float | The figure everything else derives from. |
score / label | int / string | 0–4, and very_weak … very_strong. |
strength | Strength | The enum, with ->label() and ->color() for a meter. |
components | array | Where the bits came from, per segment. |
charsetFlags | array | Which character classes are present. |
crackTimeSeconds / crackTimeHuman | float / string | Never INF; the human form is translated. |
length | int | Characters, multibyte-aware. |
It implements Arrayable, Jsonable and JsonSerializable, so toArray(), toJson() and json_encode() all work.
When it throws
Every exception extends PasswordToolkitException, so one catch covers the package.
| Exception | Thrown when |
|---|---|
NoDictionariesEnabledException | Your filters resolved to an empty pool. generate() throws rather than returning null. |
DictionaryNotFoundException | No adjective pool with an agreeing word resolves in either locale. |
InvalidOptionException | A setting is out of range or misspelled — digits outside 1–18, an unknown leetspeak mode, thresholds that do not ascend, generateUnique() unable to fill the request. |
PasswordToolkitException | The abstract base. Catch this to catch all of them. |
Injecting instead of the facade
Two contracts are bound in the container, so a test can swap the real thing for a fake rather than intercepting a static call.
| Contract | Resolves to |
|---|---|
Contracts\PasswordGenerator | The generator itself — everything under Generating above. |
Contracts\DictionaryRepository | Dictionary loading and caching, if you want to replace where names come from. |
public function __construct(private readonly PasswordGenerator $passwords) {}
Validation rule
new StrongPassword // defaults to strong StrongPassword::fair() StrongPassword::strong() StrongPassword::veryStrong() StrongPassword::atLeast('fair') // Strength|int|string
See Validation for what the failure message says and which entropy model it scores with.
Enums
All string-backed, all accepted as plain strings anywhere they appear.
| Enum | Cases |
|---|---|
Casing | title, lower, upper, preserve |
NumbersPosition | start, middle, end |
AdjectivePosition | before, after |
Leetspeak | none, basic, advanced |
Strength | very_weak, weak, fair, strong, very_strong — with label(), score() and color() |
StrengthModel | charset, structural |
Reach | global, italian, niche |
Gender | male, female, neutral |
DictionaryGroup | the twelve themes |
The two models
The same password gets two very different honest answers, and which one is right depends entirely on who chose it.
- How many strings of this length over this alphabet
- What a generic strength meter reports
- Right for a password a user chose, because you know nothing about how they chose it
- How many passwords this package could have produced
- What
generateWithReport()returns - Right for a password this package made, because an attacker searches the pool, not the alphabet
The gap is not small. Run the same generated password through both:
PasswordToolkit::strength('Ken-Impetuoso-487609');
// 131.1 bits · "very strong" · eternity to crack
PasswordToolkit::structuralReport('Ken-Impetuoso-487609');
// 36.1 bits · "fair" · 7 seconds to crack
What a report holds
['password' => $pwd, 'report' => $report] = PasswordToolkit::generateWithReport(); $report->entropyBits; // 36.05 $report->score; // 2 $report->label; // "fair" $report->displayLabel(); // "Fair" — translated $report->strength; // Strength enum: ->label(), ->score(), ->color() $report->length; // 20 $report->crackTimeSeconds; // 7.06 $report->crackTimeHuman; // "7 seconds" — translated, and never INF $report->charsetFlags; // ['lower' => true, 'upper' => true, 'digits' => true, 'symbols' => true] $report->toArray(); // Arrayable, Jsonable, JsonSerializable
Where the bits came from
The structural report breaks the number down, so you can see which setting is actually carrying the password.
$report->components; // [ // 'name' => 12.11, // log2(4,411 names) // 'adjective' => 4.17, // 'second_adjective' => 0.00, // word_count is 2 here // 'number' => 19.78, // log2(900,000) — six digits, no leading zero // 'leetspeak_bonus' => 0.00, // always, by design // 'total' => 36.06, // ]
Read that column once and the priorities are obvious: the digits carry more than half, the adjective carries least, and leetspeak carries nothing at all. Adding dictionaries is the weakest lever available — doubling every one of them buys a single bit.
Score thresholds
Bands are configurable; these are the defaults.
| bits | score | label | colour |
|---|---|---|---|
< 28 | 0 | very_weak | red |
28–35 | 1 | weak | red |
36–59 | 2 | fair | amber |
60–127 | 3 | strong | green |
≥ 128 | 4 | very_strong | green |
A default generated password lands around 36 bits — "fair", barely above the boundary. That is deliberate: it is honest about being a credential you replace, not one you keep.
The rule
use Gabrielesbaiz\PasswordToolkit\Rules\StrongPassword;
$request->validate([
'password' => ['required', new StrongPassword], // defaults to strong
'pin' => ['required', StrongPassword::fair()],
'master' => ['required', StrongPassword::veryStrong()],
'legacy' => ['required', StrongPassword::atLeast('weak')],
]);
atLeast() takes a Strength enum, its string spelling, or its score as an integer — whichever your own code already has to hand.
What the message says
The failure names both the band achieved and the band required, so the person reading it knows how far short they fell:
Both band names are translated, and so is the sentence around them. Publish the strings to reword it:
php artisan vendor:publish --tag="password-toolkit-translations"
'too_weak' => 'The :attribute is :actual. It must be at least :expected.',
Which model it scores with
By default the rule uses the charset model, because the value under validation is one the user chose — you have no idea whether it came from a password manager or a keyboard row, so the alphabet is all you can honestly reason about.
Change it globally, or for one rule:
'strength' => ['rule_model' => 'structural'], // config StrongPassword::strong()->using('structural'); // one rule
Generate
php artisan password-toolkit:generate 5 --report
Every option the builder has, on the command line. Nothing is written anywhere — it prints and exits, so it is safe to run against production config to see what your settings actually produce.
Output
| Flag | Effect |
|---|---|
{count} | How many to generate. Defaults to 1. |
--report | Add score, entropy band and offline crack time to each row. |
--json | Emit JSON instead of a table, for piping. |
--list | List the dictionaries that resolve and generate nothing. |
Choosing the pool
| Flag | Effect |
|---|---|
--only= | Restrict to these dictionary keys. |
--except= | Exclude these keys. |
--type= | people, things, or both. |
--group= | Thematic groups: food, screen, sport… |
--tag= | Dictionaries carrying all of these tags. |
--reach= | Minimum recognisability: global, italian, niche. |
--locale= | Adjective locale. Defaults to the application locale. |
--only, --except, --type, --group and --tag take either form. Repeat the flag, or pass one comma-separated value — --only=dune --only=star_wars and --only=dune,star_wars are the same.Shaping the password
| Flag | Effect |
|---|---|
--words= | 2 or 3. Three adds a second adjective. |
--case= | title, lower, upper or preserve. |
--separator= | What goes between the segments. |
--digits= | Length of the numeric segment, 1 to 18. |
--position= | start, middle or end. |
--leading-zero | Let the numeric segment begin with a zero. |
--no-numbers | Words only. Costs you every digit of entropy. |
--leet= | none, basic or advanced. |
Worked examples
# Five, with the honest numbers beside each php artisan password-toolkit:generate 5 --report # Italian, three words, read aloud at a support desk php artisan password-toolkit:generate --locale=it --words=3 --case=lower # Only names a stranger would recognise, and plenty of digits php artisan password-toolkit:generate --reach=global --digits=10 # Two themed dictionaries, piped somewhere else php artisan password-toolkit:generate 20 --only=star_wars,dune --json | jq -r '.[].password' # What actually resolves, before you commit to it php artisan password-toolkit:generate --list --group=drink # Satisfy a policy that insists on symbols php artisan password-toolkit:generate --leet=advanced --digits=8
--list is the one to reach for when a filter surprises you. It prints each dictionary that survived with its group, reach, locale, entry count and tags, then the pool totals — which is faster than reasoning about how six filters combined.Scaffold a dictionary
php artisan password-toolkit:make-dictionary my_team --type=people --locale=it
| Flag | Effect |
|---|---|
--type= | people or things. Defaults to things. |
--path= | Where to write it. Defaults to your first configured dictionary path. |
--locale= | Also scaffold an adjective file for that locale, beside it. |
--force | Overwrite a file that already exists. |
See Your own dictionaries for what to put in the file it writes.
Onboarding, end to end
The job this package exists for. Generate once, store the hash, send the plaintext, and make sure it cannot outlive its first use.
['password' => $plain, 'report' => $report] = PasswordToolkit::generateWithReport();
$user = User::create([
'email' => $data['email'],
'password' => Hash::make($plain),
'must_change_password' => true,
]);
Mail::to($user)->send(new WelcomeMail($plain, $report->crackTimeHuman));
Three things are doing work here. generateWithReport() hands you the password and its structural figure in one call, so the email can say how long it is safe to keep. $plain is never stored — it exists for the length of the request. And must_change_password is what makes 36 bits an acceptable number at all.
A support-desk reset
Somebody is on the phone and your agent has one chance to be understood. This is where the trade actually pays, so tune for the ear rather than for the meter.
$plain = PasswordToolkit::make()
->casing('lower') // no "capital F, lowercase e…"
->separator('-') // a word the agent can say: "dash"
->reach('global') // names a stranger would recognise
->digits(6)
->generate();
// "tyrannical-gaston-493282"
reach('global') is the one that matters and the one people skip: it drops the dictionaries whose names only land with an Italian audience, which is exactly the wrong thing to read to a stranger. Lower casing removes an entire class of question, and a separator you can say beats one you have to describe.
Seeding a demo tenant
A whole roster in one pass, with no two accounts sharing a password.
$passwords = PasswordToolkit::make()
->only(['star_wars', 'dune'])
->unique(User::count());
User::each(fn (User $u, int $i) => $u->update([
'password' => Hash::make($passwords[$i]),
]));
Use unique() rather than many() here. Two demo accounts sharing a password is the kind of thing nobody notices until a workshop, and duplicates are likely once the pool is narrow — two dictionaries and six digits is a smaller space than it looks.
InvalidOptionException naming how many it managed. Widen the pool or add digits; do not catch and carry on with a short list.Satisfying a policy you did not choose
Sixteen characters, mixed case, a digit and a symbol — a rule somebody wrote in 2009 and nobody can now remove.
$plain = PasswordToolkit::make()
->words(3)
->digits(8)
->leet('advanced') // adds symbols, and lengthens
->generate();
words(3) and digits(8) are real entropy; leet('advanced') is worth zero bits and the report will say so. Reach for it to pass the character-class check, and never to feel safer.Showing strength in your own UI
The enum carries everything a meter needs, so you are not mapping scores to colours by hand.
$report = PasswordToolkit::strength($request->input('password'));
$report->score; // 0..4 — the width of the bar
$report->strength->color(); // 'red' | 'amber' | 'green'
$report->displayLabel(); // "Fair" — translated
$report->crackTimeHuman; // "7 seconds" — translated
Use strength() here, not structuralReport(). The value came from a human typing into a form, and you have no idea whether it arrived from a password manager or the top row of the keyboard — the alphabet is the only honest thing to measure it against.
Rotating an existing password
The near-miss case: everything the onboarding recipe does, plus the part people forget.
['password' => $plain, 'report' => $report] = PasswordToolkit::generateWithReport();
$user->forceFill([
'password' => Hash::make($plain),
'must_change_password' => true,
])->save();
// Whatever your app uses: database sessions, Sanctum tokens, Passport grants.
$user->tokens()->delete();
Mail::to($user)->send(new PasswordRotatedMail($plain, $report->crackTimeHuman));
Start here
Most of what goes wrong is a filter that resolved to less than you expected. One command answers that before you reason about anything:
php artisan password-toolkit:generate --list
It prints every dictionary that survives your current configuration — key, label, group, reach, locale, entry count and tags — and nothing else. Add the same flags you are passing in code and the answer is in front of you.
NoDictionariesEnabledException
Your filters resolved to an empty pool. The filters combine, and tags is the one that catches people: a dictionary must carry every tag you list, not any of them.
php artisan password-toolkit:generate --list --group=food --tag=italian
Remove one filter at a time until rows appear. If enabled names a key that does not exist, it contributes nothing and no error is raised — check your spelling against All dictionaries.
DictionaryNotFoundException
No adjective pool with an agreeing word resolved, in either locale. In Italian that usually means a pack with no form matching the name's gender — a pool of only -o adjectives cannot describe a feminine name.
The resolver falls through to _default before it gives up, so this almost always means a custom pack is being found and is missing the gender it needs. Ship both forms, or mark the entry neutral.
My own dictionary is not being used
- Is its directory in
dictionaries.paths? Nothing scans by accident. - Is the file
.json, and does itskeymatch the filename? - Does the key match
[A-Za-z0-9_][A-Za-z0-9_-]*? A key that does not is rejected, not silently ignored. - Did you register it at runtime after the first generation? Dictionaries are cached — call
flushCache().
--list settles all four in one go: if it is not in that table, it did not load.
The adjectives read oddly
Your dictionary has no themed pack, so it is drawing from _default — a general pool that suits a person, a place or a thing equally, and therefore suits none of them precisely. Add {path}/{locale}/{yourkey}.json beside your names and the resolver picks it up with no further configuration.
I asked for three words and got two
The pool had fewer than two distinct agreeing adjectives. Rather than throw, the package returns what it has — fewer words is a smaller password, an exception is no password at all — and the report states the entropy you actually got, with second_adjective at 0.0.
Check the pack the dictionary is resolving to. A themed Italian pack with two entries for one lemma offers one distinct word, not two.
generateUnique() threw
It could not fill the request, and the message names how many it managed. The space is smaller than it looks: two dictionaries and no digits is a few thousand combinations, and collisions arrive long before exhaustion.
Widen the pool, add digits, or raise unique_attempts_multiplier. Do not catch it and carry on — a short list is a silent duplicate later.
Passwords are in the wrong language
The locale follows your application unless you set it. But the more common surprise is that it looks half-translated — and that is correct behaviour: names carry a language of their own. An Italian wine stays Italian in an English application because Barolo is its name, while the adjective beside it is English. See Translated names.
Leetspeak did not raise the score
It never will. A deterministic transform cannot enlarge the space an attacker who has read your config is searching, so it is credited zero bits deliberately. Use it to satisfy a character-class policy; the entropy has to come from digits or words.
The validation rule rejects everything
Two likely causes. Your thresholds may have moved — they must ascend, and raising strong raises your signup bar with it. Or rule_model is set to structural, which measures a user-chosen password against pools it never came from and returns a number close to meaningless. Charset is the right model for a form field.
Entropy figures changed after upgrading
Twice, and both times the old number was wrong. 1.x picked a dictionary file before it picked a name, which made the reported pool bigger than the space being searched. Then the numeric segment was found to be credited a full decade of values when leading zeros are never drawn — six digits are 900,000, not a million.
The command is not found
php artisan package:discover
A stale cached package manifest. This is also the first thing to try if the config file appears not to take effect after an install.
Security
The trade, stated
Memorable passwords are a deliberate entropy-for-usability trade, not an oversight — the package reports exactly what the trade cost. Two things it does not trade:
- Every random choice uses
random_int(), names included. - Dictionary keys and locales are validated before they touch a path, and registered names are stripped to letters, digits and the separator.
Reporting a vulnerability
Found something? SECURITY.md has the reporting route. Please do not open a public issue.
Contributing
Issues and pull requests are welcome. CONTRIBUTING.md has the setup and the house rules; the short version is that there is no CI, so three local gates are the contract:
composer format # pint composer analyse # phpstan, level 6 composer test # pest
A new dictionary is the easiest contribution to make and the hardest to get right. Every name must be real and verifiable, ASCII-only — these strings get typed by people — and no more than three words. Italian entries use the form an Italian would actually say, which for dubbed material is the dub.
Security vulnerabilities
One thing worth stating before you report it: memorable passwords being weaker than random ones is not a vulnerability. It is the deliberate trade this package exists to make, and the strength report states the cost in bits rather than hiding it. A flaw in how that trade is made — a biased draw, a path that escapes its directory, an unsanitised value reaching a password — very much is.
Credits
Written and maintained by Gabriele Sbaiz, with thanks to everyone who has contributed.
It stands on work it does not contain: Laravel, and spatie/laravel-package-tools — the only runtime dependency beyond the framework itself.
Support this package
This is maintained on evenings and weekends, alongside a full-time job writing insurance software. Keeping it green across new Laravel majors is the unglamorous part, and it is what keeps it installable in your composer.json next year too.
The code took a few weekends. The 201 dictionaries did not — those were evenings spent checking that every name is real, spelled as an Italian would dub it, and safe to read aloud at a support desk.
- 01Star the repository
Free, thirty seconds, and it is the first signal another developer looks at.
- 02Become a sponsor
From $5 a month. Company tiers get a logo in the README.
- 03Open a good issue
A clear reproduction is worth more than you think — most of what shipped in 2.0 started as somebody describing a real problem precisely.
- 04Tell another Laravel developer
Word of mouth is how small packages survive.
Disclaimer
This package is provided as is. It generates passwords that are deliberately weaker than random ones in exchange for being memorable, and it reports that weakness honestly. Deciding whether that trade is acceptable for a given use — and configuring, testing and operating it accordingly — is the deploying application's responsibility, not this package's.
Licence
MIT. See LICENSE.md. The MIT warranty disclaimer and limitation of liability apply in full, alongside the section above.
Why 2.0 exists
1.x was a single static class that re-read its data directory on every call, picked a dictionary file before it picked a name, and returned string|array|null from one method. It was Italian-only by construction: adjectives sat in one flat directory with gender agreement baked in, so adding English meant writing 91 new files.
2.0 fixes all of that. The generator is an injectable object, dictionaries are cached and flattened, adjectives resolve through a locale chain, and you can add dictionaries of your own without forking the package.
What changed
| Change | Shimmed? |
|---|---|
generate($count) → generateMany($count) | no |
generate() throws instead of returning null | no |
poolSizes() returns ['names' => …, 'adjectives' => …], not a list | no |
Exceptions no longer extend \InvalidArgumentException | no |
Config name_types → dictionaries.enabled / .except | yes, with a deprecation |
leetspeak_conversion: 'no' → 'none' | yes, 'no' still parses |
clearPoolCache() → flushCache() | yes, alias kept until 3.0 |
Entropy::LABELS → Enums\Strength | yes, the constant still exists |
| Static calls on the class → an instance behind the facade | yes, via the facade |
Adjectives move to Data/Adjectives/{locale}/{key}.json | no, if you forked the data |
humanizeSeconds(90) returns "1 minute" | no — it was a bug |
| Reported entropy drops: file-first picking overstated it | no — the old figure was wrong |
| Requires PHP 8.2 (was 8.0) | no |
strength(), structuralReport(), generateWithReport(), generateManyWithReport() and every StrengthReport property are unchanged.
The four edits
// 1 — batch generation - $passwords = PasswordToolkit::generate(10); + $passwords = PasswordToolkit::generateMany(10); // 2 — null handling - $p = PasswordToolkit::generate(); - if ($p === null) { /* ... */ } + try { $p = PasswordToolkit::generate(); } + catch (NoDictionariesEnabledException $e) { /* ... */ } // 3 — pool sizes - [$names, $adjectives] = PasswordToolkit::poolSizes(); + ['names' => $names, 'adjectives' => $adjectives] = PasswordToolkit::poolSizes(); // 4 — caught exceptions - catch (\InvalidArgumentException $e) + catch (PasswordToolkitException $e)
Configuration
The shim keeps a 1.x config working, so nothing breaks on deploy. Migrate it anyway — the new file is around a hundred lines shorter and does not have to be kept in filesystem sync by hand. Re-publishing beats hand-editing:
php artisan vendor:publish --tag="password-toolkit-config" --force
Then reapply your choices:
- 'name_types' => [ - 'people' => ['star_wars' => true, 'cartoons' => false, /* 47 more */], - 'things' => ['italian_wines' => true, /* 41 more */], - ], + 'dictionaries' => [ + 'enabled' => '*', + 'except' => ['cartoons'], + ], - 'leetspeak_conversion' => 'no', + 'leetspeak_conversion' => 'none',
adjective_position at null. Word order belongs to the language, and each locale declares its own. 1.x was Italian-only and always put the adjective last, so an Italian install produces the order it always did.Your entropy figures will drop
1.x picked a dictionary file first, then a name inside it — which made a 20-name dictionary as likely as a 200-name one, and made the reported pool larger than the space actually being searched. 2.0 picks uniformly across every enabled name, and a later fix stopped crediting the numeric segment entropy it never had.
Full detail in UPGRADE.md.
2.0.1 — 2026-09-28
Performance only. No API, behaviour or output changes — passwords generated with the same options come out exactly as they did in 2.0.0.
Changed
- Dictionaries decode one at a time instead of all at once. Enabling a single dictionary no longer reads 201 files and builds 4,411 entries. Cold start for one dictionary drops from 19.0 ms to 3.9 ms, and the memory it holds from 0.92 MB to 0.14 MB. Enabling every dictionary costs what it always did.
- Adjective pools are narrowed by gender once, not once per password.
- The last adjective drawn no longer rebuilds the pool it came from. At the default
word_countof 2 that removes the rebuild entirely.
Generating 2,000 passwords: 27.3 ms to 13.3 ms at two words, 32.1 ms to 20.6 ms at three.
2.0.0 — 2026-09-23
A rewrite rather than a point release: the whole generator, the data layout and the public API changed. Upgrading from 1.x covers what you have to edit.
Added
- English as a first-class locale, with adjectives resolving through a four-step chain and word order declared per language.
- Your own dictionaries, from a directory, inline config or a runtime registration.
- A fluent builder —
make()— immutable, with every option as a method. - Two artisan commands:
generatewith eighteen flags, andmake-dictionary. - A validation rule,
StrongPassword, with configurable bands and a choice of entropy model. - Dictionary metadata — group, tags, icon, reach — and the methods to filter on it.
- A third word:
word_count, two distinct adjectives drawn without replacement. - Casing, leading zeros, configurable thresholds and a unique-attempt multiplier.
- 110 new dictionaries, taking the shelf from 91 at 1.8.0 to 201, and 4,411 names.
Changed
- The generator is an injectable singleton behind a facade, not a static class.
- Dictionaries are cached and flattened; a batch of a thousand does one scan, not a thousand.
- Names are picked uniformly across every entry, not across files.
numbers_digitsdefaults to 6 everywhere — the config said 6 whileOptionssaid 4.
Security
- Every random choice uses
random_int(). 1.x drew names withmt_randand only the digits were CSPRNG. - Locales and dictionary keys are validated before they touch a path. Both are interpolated into a filename, so a request value reaching
->locale()could otherwise read any JSON the process could see. - Registered names are sanitised. Values straight from a database cannot carry a quote, a semicolon or a newline into a password.
Fixed
- Crack time no longer returns
INF— computed in log space, sotoArray()can be JSON-encoded. - The numeric segment is no longer credited entropy it never had. Six digits are 900,000 values, not a million; every figure was about 0.15 bits optimistic.
- Very large crack times no longer print "0 centuries." The float was cast to int before the range check and wrapped past
PHP_INT_MAX. humanizeSeconds(90)says"1 minute", not"1 minutes".- Leetspeak is credited zero bits. It was worth 6 or 12, which was fiction.
Removed
generate($count), thestring|array|nullreturn, and the 91 hand-maintained config booleans.
1.7.0 and earlier
Tagged releases exist from 1.3.0 onward but were never written up. Compare on GitHub if you need the detail.
Full file: CHANGELOG.md.