divineomega/symfony-password-exposed-bundle
Symfony bundle that checks whether a password has appeared in a data breach, using the haveibeenpwned.com API with configurable PSR-18 client, PSR-6 cache, and PSR-17 factories. Supports PHP 7.4+ and Symfony 4.4 through 8.
Verdict up front: low-confidence dependency. This is a Symfony bundle, not a framework-agnostic PHP library, so for a Laravel-primary estate it is not directly installable — the value must be re-derived from its underlying core library (jord-jd/password_exposed) or reimplemented. Signal quality is weak (1 star, 0 dependents, single maintainer, README-only docs), and the actual business logic is small enough that dependency risk likely exceeds build cost.
Architecture fit
PasswordExposedCheckerInterface, a Symfony PasswordExposed validation constraint, and YAML config for enable, http_client, cache, cache_lifetime, and PSR-7 request_factory/uri_factory.cache.app, Symfony HttpClient, ContainerBuilder) map to Symfony services and Symfony config; Laravel exposes a different container, Cache facade, and Http client. In a Laravel codebase the bundle's wiring layer is dead weight.Constraint; Laravel's validator uses Rule objects. Any reuse means an adapter, at which point you own more glue than logic.Integration feasibility
jord-jd/password_exposed core (or similar) and expose it through a Laravel service provider + Rule + config file. Effort estimate: <1 day for a senior PHP engineer, including tests.GET https://api.pwnedpasswords.com/range/{prefix}, scan the response body for the remaining 35 chars, cache the prefix response. This is a bounded, well-specified problem — roughly 60–120 lines plus tests — and it removes third-party version, license, and maintainer risk entirely.Technical risk
| Risk | Severity | Notes |
|---|---|---|
| Framework mismatch (Symfony bundle in Laravel estate) | High | Bundle is unusable as-is; forces core-lib adoption or reimplementation |
| Maintainer/bus factor — single author, 1 star, 0 dependents | High | No community backstop; abandonment is plausible |
| LGPL-3.0 | Medium | Generally workable for SaaS (no distribution, no modification), but requires legal sign-off; not a permissive-license default |
| Release cadence looks automated (v1.0.0 "automated migration release", v1.0.1 docs-only rename) | Medium | Indicates low active investment, not active hardening |
| External API dependency (HIBP) | Medium | Latency + availability directly on the registration path |
| Rate limits / ToS interpretation | Medium | Prior analysis cites 2,000 checks/day; the Pwned Passwords range endpoint is widely used without a key and with generous limits — verify current HIBP terms before committing, and design caching as if the limit is tight regardless |
| Stale cache → false negatives | Medium | Default cache_lifetime of 30 days means newly breached passwords pass the gate |
| Release date metadata (2026-07-18) not reconcilable with observation | Low | Treat provenance/registry metadata as unverified; pin versions by hash |
| Docs coverage | Medium | README only; no stated test suite, no supported-version matrix, no deprecation policy |
Key questions
Stack fit
| Concern | Bundle (Symfony) | Laravel target | Recommendation |
|---|---|---|---|
| HTTP | Symfony HttpClient |
Illuminate\Support\Facades\Http (Guzzle) |
Use Laravel's client; bypass bundle config |
| Cache | cache.app (PSR-6/PSR-16) |
Laravel Cache (PSR-16 compatible) |
Use Cache::remember() with prefix-keyed entries |
| Config | YAML bundle tree | config/*.php + .env |
Expose enable, cache_ttl, fail_open as config |
| DI | Symfony container/autowiring | Service provider binding | Bind an interface to our adapter |
| Validation | Symfony Constraint |
Illuminate\Validation\Rule |
Implement a custom PasswordNotExposed rule |
| PSR-7 factories | Configurable | Not needed | Laravel/Guzzle handles request construction |
Migration / adoption path
jord-jd/password_exposed only if it proves framework-agnostic under test; otherwise implement in-house.PasswordExposureChecker interface with one method (isExposed(string $password): bool) and one concrete implementation. Everything else — controller, form request, rule, listener — depends on the interface only. This keeps the dependency swappable in one file.PasswordNotExposed) and attach it to registration, reset, and change-password requests. Prefer a soft block (validation error with guidance) over a hard rejection for the first release.cache_lifetime (suggest 30 days for the prefix set, and treat it as a cache-warm optimisation rather than correctness). Key with a versioned prefix so a TTL change is a config deploy, not a cache flush.Maintenance
composer.lock, and record the decision in an ADR so a future team knows why a 1-star package appears in composer.json — or, better, why we implemented it ourselves.Support
Scaling
Failure modes
str_contains on the response body can produce false positives across line-delimited records. This is the most likely correctness defect in a hand-rolled implementation — cover it with fixture-based tests.Ramp-up
Bottom line for the roadmap: the capability is worth shipping — breach-correlated password rejection is cheap, high-signal, and defensible under GDPR/NIST framing. The package is not the right delivery vehicle for a Laravel estate. Recommend adopting the capability via a thin in-house or core-library-backed adapter behind a flag, treating this bundle as a reference implementation and a design precedent rather than a runtime dependency.
How can I help you explore Laravel packages today?