nowo-tech/password-strength-bundle
Password-strength bundle for PHP/Symfony apps: estimate password quality, enforce configurable rules and policies, and return clear feedback for forms and APIs. Simple service and validator integration with sensible defaults.
Read this first as a reference implementation, not a dependency. nowo-tech/password-strength-bundle is a Symfony bundle: it extends Symfony's PasswordType, ships a validator constraint with configurable strength policies, emits an HTML pattern attribute, and includes frontend assets for live feedback. There is no Laravel service provider, no config publish tag, no facade, and no illuminate/* dependency. composer require in a Laravel app will install code you cannot wire up without a Symfony runtime.
So the practical first step is: open the constraint/validator class, the form type extension, and the DI config section, in that order.
PasswordType and how the same policy drives both the server-side constraint and the rendered pattern attribute.config/password.php blueprint.First use case: replacing the usual scattered ['required', 'string', 'min:8', 'regex:/[A-Z]/', 'regex:/\d/'] soup on registration and password-reset with a single named policy that also feeds the client-side meter.
Your day-one equivalent in Laravel:
// app/Providers/AppServiceProvider.php (the "DI config" analogue)
public function boot(): void
{
Password::defaults(fn () => Password::min(12)
->mixedCase()
->numbers()
->symbols()
->uncompromised());
}
// app/Http/Requests/RegisterRequest.php
public function rules(): array
{
return ['password' => ['required', 'confirmed', Password::defaults()]];
}
That gets you the server-side half of what the bundle does. The remaining value you'd port is the shared policy object and the client meter generated from it — covered below.
1. One policy object, three consumers. The bundle's core idea is that the server rule, the HTML pattern, and the live meter all derive from the same configuration. Replicate that with a small value object:
final readonly class PasswordPolicy
{
public function __construct(
public int $minLength = 12,
public int $minScore = 3,
public array $requiredClasses = ['lower', 'upper', 'digit', 'symbol'],
public bool $checkBreached = true,
) {}
public function pattern(): string { /* build once, reuse in Blade */ }
public function rules(): array { /* Password::min(...)->... */ }
}
Bind it as a singleton from config/password.php. Now Blade::component('password-field', [...]), your FormRequest, and your JS entrypoint all read the same numbers, and a policy change is a one-line config edit.
2. Wrap it in a custom rule for composite checks. Laravel's Password rule covers length/classes/breach but not "score above threshold" or "not similar to email/name". A ValidationRule around the policy keeps the FormRequest declarative:
final class StrongPassword implements ValidationRule
{
public function __construct(private PasswordPolicy $policy) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// delegate to Password::defaults(), then add zxcvbn-style scoring
// and reject values too similar to $this->request->input('email').
}
}
Return a list of unmet requirements in the failure message only if you want the checklist UX; otherwise keep it generic.
3. Mirror the live feedback, don't gate on it. The bundle's JS is a UX hint. Keep the server authoritative: the meter colours the bar and toggles a submit-button disabled state at most, and you always re-validate server-side. Handle input, paste, and autofill (see Gotchas). A ~60-line module in your existing Vite entrypoint beats importing a foreign asset tree.
4. Generate the pattern attribute from the policy. Progressive enhancement only — it catches typos before a round trip. Set it via the Blade component so it never drifts from rules().
5. Apply on set, not on verify. Register, password reset, and change-password get the strong rule. The login endpoint gets required|string and nothing else. This is the single most important structural decision, and it's easy to get wrong when porting a validator that was designed to attach to a form type.
6. Roll out in warn mode. Add the rule, log the failures (Log::info('weak_password', ['policy' => $id, 'user_id' => $id]) — never the password), then enforce after a week. Prevents a support spike when the threshold moves.
7. Test with a dataset, not one case. Pest/PHPUnit datasets of weak/strong values asserting pass/fail and the exact error payload; a snapshot test on pattern(); a browser-level test that paste and autofill trigger the meter.
uncompromised() makes outbound HTTP calls to the Have I Been Pwned range API (k-anonymity). That means latency on every registration, a hard dependency on network egress, and failures in offline CI. Cache positive results, decide explicitly between fail-open (skip the check on timeout) and fail-closed, and fake the HTTP client in tests.min:12 in the FormRequest and forgets the JS, the meter lies. Derive the JS config from the same policy — emit it as a JSON blob in a data- attribute or a small inline config.pattern ≠ PHP regex. Anchor both ends explicitly, watch escaping inside the attribute, and remember PHP's Unicode handling differs from browser engines (notably the newer v flag vs u). Test in real browsers; treat the pattern as a hint, never as the rule.public/build path, take the concept and rewrite it against your existing Vite entrypoint. Vendoring foreign bundles into a Laravel asset pipeline is a maintenance trap.min/max in Laravel count characters; maxlength in HTML counts UTF-16 units. Cap length (e.g. max:72 or max:255 with a modern hasher) and don't silently truncate — a password that "fits" in the browser may not fit the hash.keyup. Bind input and change, and never block paste — it's an accessibility and usability regression that also pushes users toward weaker, typeable passwords.app/Support and keep them under your own tests.form_widget, trans filters, and asset() don't translate one-to-one. Rewrite the markup as a Blade component rather than translating line by line.php artisan tinker, or a unit test calling validate() directly) before wiring into the FormRequest — Laravel's ValidationException message array makes it hard to tell which sub-check rejected once several are composed.How can I help you explore Laravel packages today?