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.
The package is a Symfony bundle, not a Laravel package. In Laravel you do not install the bundle itself — you install the underlying framework-agnostic library it wraps (jord-jd/password_exposed) and bind it into Laravel's container yourself. The bundle's real value to a Laravel dev is as documentation of the checker's constructor surface (HTTP client, PSR-17 factories, cache, cache lifetime) and as a reference for how to wrap it.
First steps:
composer require jord-jd/password_exposed
composer require guzzlehttp/guzzle nyholm/psr7
guzzlehttp/guzzle (v7+) ships a Psr\Http\Client\ClientInterface, so it works as the PSR-18 HTTP client the library expects.nyholm/psr7 gives you PSR-17 request/URI factories (Nyholm\Psr7\Factory\Psr17Factory implements all of them) without dragging in Symfony.symfony/cache's Psr16Cache directly or write a ~30-line PSR-16 adapter over Laravel's Cache facade (recommended, see below).Look at vendor/jord-jd/password_exposed/src first to confirm the checker constructor signature — the Symfony bundle's config keys (http_client, cache, cache_lifetime, request_factory, uri_factory) map to it, but the exact types (PSR-16 vs PSR-6 cache) should be verified against the installed version.
First use case: add a PasswordExposed validation rule to your registration FormRequest so users cannot sign up with a breached password. That is the canonical 15-minute win.
Service provider binding. Register the checker as a singleton so the underlying Guzzle client and cache adapter are reused:
namespace App\Providers;
use App\Support\Cache\LaravelPsr16Cache;
use GuzzleHttp\Client;
use Illuminate\Support\ServiceProvider;
use JordJD\PasswordExposed\Interfaces\PasswordExposedCheckerInterface;
use JordJD\PasswordExposed\PasswordExposedChecker;
use Nyholm\Psr7\Factory\Psr17Factory;
class PasswordExposedServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(config_path('password_exposed.php'), 'password_exposed');
$this->app->singleton(PasswordExposedCheckerInterface::class, function ($app) {
$cfg = $app['config']['password_exposed'];
$client = new Client(['timeout' => $cfg['timeout'] ?? 5.0]);
$psr17 = new Psr17Factory();
return new PasswordExposedChecker(
$client,
new LaravelPsr16Cache($app['cache.store']),
(int) $cfg['cache_lifetime'],
$psr17, // request factory
$psr17, // uri factory
);
});
}
}
Config file (mirrors the bundle):
// config/password_exposed.php
return [
'enable' => env('PASSWORD_EXPOSED_ENABLE', true),
'cache_lifetime' => 60 * 60 * 24 * 30,
'timeout' => 5.0,
'fail_open' => true, // allow signup if HIBP is down
];
PSR-16 cache adapter — Laravel's cache is not PSR-16, so wrap it:
namespace App\Support\Cache;
use Illuminate\Contracts\Cache\Repository;
use Psr\SimpleCache\CacheInterface;
class LaravelPsr16Cache implements CacheInterface
{
public function __construct(private Repository $cache) {}
public function get(string $key, mixed $default = null): mixed
{
return $this->cache->get(self::key($key), $default);
}
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
{
if ($ttl instanceof \DateInterval) {
$ttl = (new \DateTimeImmutable())->add($ttl)->getTimestamp() - time();
}
return $this->cache->put(self::key($key), $value, $ttl);
}
public function delete(string $key): bool { return $this->cache->forget(self::key($key)); }
public function clear(): bool { return true; }
public function has(string $key): bool { return $this->cache->has(self::key($key)); }
public function getMultiple(iterable $keys, mixed $default = null): iterable { /* loop get() */ }
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool { /* loop set() */ }
public function deleteMultiple(iterable $keys): bool { /* loop delete() */ }
private static function key(string $key): string { return 'pwd-exposed:'.sha1($key); }
}
Validation rule — the idiomatic Laravel hook. Do not try to use JordJD\PasswordExposed\Symfony\Validator\Constraints\PasswordExposed — it extends Symfony's Constraint and is wired to Symfony's validator component, not Laravel's:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use JordJD\PasswordExposed\Interfaces\PasswordExposedCheckerInterface;
class NotExposed implements ValidationRule
{
public function __construct(private PasswordExposedCheckerInterface $checker) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! config('password_exposed.enable')) {
return;
}
try {
if ($this->checker->isExposed((string) $value)) {
$fail('This password has appeared in a known data breach. Please choose a different one.');
}
} catch (\Throwable $e) {
if (! config('password_exposed.fail_open')) {
$fail('We could not verify your password right now. Please try again.');
}
report($e); // log only the exception, never $value
}
}
}
FormRequest usage — the day-one integration point:
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'string', 'min:12', 'confirmed', new NotExposed(app(PasswordExposedCheckerInterface::class))],
];
}
}
Controller injection (for API/JSON flows that bypass FormRequests):
public function register(Request $request, PasswordExposedCheckerInterface $checker)
{
$data = $request->validate([...]);
if (config('password_exposed.enable') && $checker->isExposed($data['password'])) {
abort(422, 'Password exposed in a known breach.');
}
// hash + persist
}
Audit / background checks. For bulk audits (e.g. flagging existing users whose hashes appear in a new leak), do not iterate inside a request — dispatch a queued job per user batch, and rate-limit with RateLimiter or Bus::batch()->allowFailures() to stay within HIBP limits.
HttpClientInterface, cache.app) and its PasswordExposed constraint is a Symfony Constraint. Install the underlying library, not the bundle.Http facade is not PSR-18 — the library needs an actual Psr\Http\Client\ClientInterface. Use Guzzle 7 directly, or wrap symfony/http-client's Psr18Client. Do not pass app('http').['timeout' => 5.0] is a reasonable starting point.cache_lifetime: 2592000 in the bundle represents. That means a password exposed today may not be flagged for up to a month. If your threat model needs freshness, use a shorter TTL for the "not exposed" result than the "exposed" one (you can distinguish inside your cache adapter).Bcrypt::make(), do it before calling isExposed() too, otherwise " hunter2 " and "hunter2" will be checked against different hashes and one of them will slip through.$value into a catch block. Log the attribute name and the boolean result only.enable is not env-aware. The bundle's enable: true is a static flag; unlike a real Laravel package it does not auto-disable in the testing environment. Guard the rule yourself with if (app()->environment('testing')) { return; } or set PASSWORD_EXPOSED_ENABLE=false in .env.testing, otherwise your test suite will hit HIBP and be slow/flaky.TestCase::setUp(): $this->app->bind(PasswordExposedCheckerInterface::class, fn() => new class implements PasswordExposedCheckerInterface { public function isExposed(string $p): bool { return false; } }); — never let unit tests touch the real API.use statements as dynamic linking, others don't).jord-jd/password_exposed pulls PSR-7/18 interfaces that are also used by Guzzle, symfony/http-client, and Laravel's own packages. Composer will typically resolve this cleanly, but if you also use php-http/discovery, check that Guzzle is chosen as the default PSR-18 client (or set PSR18_CLIENT explicitly) so you are not double-installing an HTTP layer.123456, password, etc. — those should be rejected instantly without an HTTP round-trip, before isExposed() is ever called.How can I help you explore Laravel packages today?