Product Decisions This Supports
- Scoping one shippable slice of "improve account security." This package answers exactly one question — has this password appeared in a known breach? — which turns a vague epic into a testable increment: block known-breached passwords at signup and password change, measure blocked attempts, credential-stuffing-related reset tickets, and support contacts. Ship in days, not a quarter.
- Build vs. buy vs. framework-native — the decision you must actually make first. If you're on Laravel 8+, the framework already ships
Password::min(8)->uncompromised(), which calls the same Pwned Passwords range API with k-anonymity. That means the honest framing is not "buy a security product," it's "adopt a maintained wrapper, use the built-in validator, or write 40 lines." This package earns its place when you need the check outside the validator pipeline — inside a registration service, a JSON API endpoint, an admin tool, a job, or a flow that already holds a SHA-1 hash rather than plaintext — or when you need an explicit tri-state instead of a pass/fail boolean.
- The block-vs-warn and fail-open-vs-fail-closed policy call.
PasswordStatus::{EXPOSED, NOT_EXPOSED, UNKNOWN} forces two product decisions to be written down rather than defaulted: do you hard-block on EXPOSED or warn-and-allow, and when the third-party API is down (UNKNOWN) do you reject the user or let them through? Fail-closed means an HIBP outage becomes your onboarding outage — that is a business-continuity decision, not an implementation detail, and it needs an owner before launch.
- Where in the funnel it lives. Registration and password-reset/change are the cheap, high-value insertion points. Login-time enforcement is a different, far more disruptive product (it forces resets on existing users). There is no batch endpoint here, so retro-scanning your existing user table is a separate initiative with separate scoping — do not let it ride along as a "freebie."
- Sequencing against MFA and rate limiting. This is an additive, low-cost control, not a fix for account takeover. Position it as a fast-following quick win alongside MFA and login throttling, not as the item that closes the ATO risk in your roadmap narrative.
- Privacy and legal review posture. Only the first five characters of the SHA-1 hash leave your network; plaintext never leaves the app, and cache keys are prefix-based rather than full-hash (so your cache isn't a credential store). That materially shortens a DPO/security review compared with any alternative that posts credentials to a vendor — and it's the strongest single argument for this approach over a "just call an API with the password" shortcut.
- Dependency-risk budgeting. Single maintainer, 223 stars, zero dependents, LGPL-3.0, but a genuinely active release trail (PHP 8.1–8.5 CI, Psalm, lint, response-parsing fixes, Composer constraint relaxations). Acceptable for a non-differentiating utility because the exit cost is low — you can vendor or rewrite it in an afternoon. Do not plan a fork around it; do plan the exit.
- Latency and UX budget. Every check is a synchronous outbound HTTPS call (roughly 100–300 ms, first request per hash prefix). That's a UX decision: spinner on signup, timeout value, and what the user sees on
UNKNOWN. Prefix-keyed caching makes steady-state cost negligible across a real user base, but the cold path is user-visible.
When to Consider This Package
Adopt if:
- You're on PHP 7.1+/8.x (Laravel or otherwise) and want breach screening wired into registration/reset without new infrastructure or a vendor contract.
- Your need sits outside Laravel's validator — service layer, API endpoints, queued jobs, hash-in-hand flows — where
Password::uncompromised() doesn't reach.
- You want the k-anonymity property available as a review artifact rather than something you implement and defend yourself.
- You want an explicit
UNKNOWN state so fail-open/fail-closed is a deliberate, auditable choice.
- You're comfortable treating it as a thin, replaceable wrapper — and have accepted PSR-6/PSR-18 wiring as a small integration task, since Laravel's cache and HTTP client aren't PSR-6/PSR-18 natively and you'll likely pull in Symfony Cache and Guzzle.
Look elsewhere if:
- You're on Laravel 8+ and only need signup-time blocking on a standard form — use the built-in
uncompromised() rule and skip the dependency entirely.
- You need offline/air-gapped checking, a locally hosted corpus, or a contractual SLA. This is a live third-party call with an
UNKNOWN fallback, not a dataset you own.
- You need bulk or retroactive scanning of an existing user table, or admin batch tooling. You'd be looping one API call per password.
- You need email/account breach lookups — this covers passwords only.
- You need a vendor with named support and security attestations, or counsel rejects LGPL-3.0 outright (a real consideration if you distribute the PHP app rather than operate it as a service).
How to Pitch It (Stakeholders)
To executives: This is a days-not-quarters security win with no new infrastructure, no vendor contract, and no new data leaving our systems in readable form — only a five-character hash prefix is transmitted, which keeps the privacy review short. It measurably reduces a well-understood attack path (credential stuffing against reused, already-breached passwords) at signup and password change, and it's fully reversible: one config change turns it off. Frame it as one of several cheap controls alongside MFA, not a replacement for them, and set the expectation that its value is risk reduction on new credentials, not remediation of existing accounts.
To engineering: Small surface area, framework-agnostic, DI-friendly — you can inject a custom PSR-18 HTTP client and a PSR-6 cache pool, and the package handles TLS validation via paragonie/certainty. It returns a three-state enum so error handling is explicit rather than implicit, supports SHA-1-hash input so plaintext can stay out of some code paths, and is CI-backed (PHPUnit, Psalm, lint on PHP 8.1–8.5). Caveats to state up front: one maintainer, zero dependents, LGPL-3.0 (check distribution model with legal), a synchronous outbound call on the cold path, and real wiring work for the PSR-6 cache and PSR-18 client in a Laravel app. Mitigate all of that by timeboxing the integration and keeping the call behind your own thin interface, so swapping to Laravel's built-in validator — or to 40 lines of your own — is a one-file change.