laravel/framework: Fix Str::password() returning extra characters and throwing Va…
Contributed by bunyaminbilenkaratas
Part of the framework v13.36.0 release
Laravel's Str::password() helper gets a small but important correction in the 13.x series (PR #61521). Two edge cases previously misbehaved: requesting a password shorter than the number of enabled character pools returned more characters than asked for, and disabling every pool crashed with an unhandled ValueError bubbling out of PHP's random_int(). The helper now guarantees the exact requested length, returns an empty string for zero or negative lengths, and throws a clear InvalidArgumentException when no pools are enabled. If you're upgrading from v12.70.0, everyday usage is unaffected — only edge-case callers will notice a difference.
What changed
Three things were fixed inside Illuminate\Support\Str::password():
- The active character pools (letters, numbers, symbols, spaces) are now shuffled and capped with
take(max(0, $length))before the helper pushes its "one guaranteed character per pool" picks, so the initial characters can never exceed the requested length. - A new
$options->isEmpty()guard throwsInvalidArgumentExceptionwith the message "At least one character pool must be enabled." instead of letting PHP's engine fail deep inside the collection pipeline. - Non-positive lengths are clamped, so
Str::password(0)and negative lengths safely return an empty string.
The method signature is unchanged, and new tests cover all three scenarios.
The old behavior
Before this fix, the helper unconditionally pushed one character from every active pool before calculating the remainder. With the default flags — letters, numbers, and symbols enabled, spaces off — that's three pools:
use Illuminate\Support\Str;
// Three pools active by default
Str::password(1); // e.g. "k4!" — three characters instead of one
Str::password(2); // e.g. "x7#" — still three characters
Str::password(0); // e.g. "a9~" — even a zero length produced output!
Meanwhile, disabling every pool hit a harder failure. With an empty pool collection, the filler loop called random_int(0, -1), which is invalid in PHP:
Str::password(32, false, false, false, false);
// ValueError: random_int(): Argument #1 ($min) must be less than or
// equal to argument #2 ($max)
Neither failure mode was handled by Laravel; both surfaced as raw, confusing errors (or worse, silently wrong passwords) at runtime.
The new behavior
The same calls now behave predictably:
Str::password(1); // e.g. "q" — exactly one character
Str::password(2); // e.g. "3v" — exactly two characters
Str::password(0); // "" — empty string
Str::password(-2); // "" — empty string
Str::password(32, false, false, false, false);
// InvalidArgumentException: At least one character pool must be enabled.
Two nuances worth knowing:
- Lengths at or above the pool count are unchanged in spirit. For a typical
Str::password(32), every enabled pool still contributes at least one guaranteed character; the pools are simply picked in random order now, and the final string was already shuffled. - Very short lengths draw from a random subset of pools. You can't fit one character from each of three pools into a two-character password, so
Str::password(2)picks two random pools for its guaranteed characters. That's an inherent constraint, not a regression.
Why it matters
Str::password() typically feeds credentials: temporary passwords in seeders, generated accounts in tests, or passwords handed to new users. A helper that returns three characters when you asked for one silently produces dangerously weak credentials — a three-character password is trivially brute-forceable. The crash case is equally painful: an unhandled ValueError from random_int() gives you no hint that the real problem is a misconfigured pool combination. Replacing it with a descriptive InvalidArgumentException fails fast and tells you exactly what to fix.
Upgrade impact
For the vast majority of applications, no action is required — Str::password(32) and similar calls return the same style of output as before. Check your code for these edge cases:
- If you pass user-controlled flags into the helper (for example, a "password options" form), wrap the call in a try/catch, since disabling all pools is now an explicit exception rather than a crash:
use Illuminate\Support\Str;
use InvalidArgumentException;
try {
$password = Str::password($length, $letters, $numbers, $symbols, $spaces);
} catch (InvalidArgumentException $e) {
// Surface a validation error or fall back to sensible defaults
}
- If any test or assertion relied on the old inflated output (e.g., expecting
strlen(Str::password(1)) === 3), update it to expect the exact requested length. - If you passed
0or a negative length and consumed the previously returned characters, those calls now yield an empty string.
Takeaways
Str::password($n)now returns exactly$ncharacters, even when$nis smaller than the number of enabled pools.- Zero and negative lengths return an empty string instead of leaking pool characters.
- Disabling all pools throws
InvalidArgumentExceptionwith a clear message instead of an unhandledValueError. - Default usage (
Str::password(32)) behaves as before — no changes needed for most apps upgrading from v12.70.0.