Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Symfony Password Exposed Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.
  • For caching you either use 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.

Implementation Patterns

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.

Gotchas and Tips

  • The bundle itself is not usable in Laravel. Its auto-registered services use Symfony DI (HttpClientInterface, cache.app) and its PasswordExposed constraint is a Symfony Constraint. Install the underlying library, not the bundle.
  • Laravel's 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').
  • Fail open vs. fail closed. The HIBP API is a third-party dependency; an outage should not block logins. Catch exceptions and default to allowing the password (fail-open) unless you are in a high-compliance signup — make the choice explicit in config, not by accident.
  • Set a client timeout. A default Guzzle timeout (or none) can hang a registration request for 30s+ during an HIBP slowdown. ['timeout' => 5.0] is a reasonable starting point.
  • Respect HIBP's rate limits. The free API allows ~1,500 requests per 15 minutes. Your cache is doing the heavy lifting; do not disable it in staging, or you will get 429s and silently fail-open.
  • Cache is 30 days by default — this is what the 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).
  • Normalize the password the same way your hasher will. If you trim or lowercase before passing to 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.
  • Never log the password. The library uses HIBP's k-anonymity protocol (only a SHA-1 prefix leaves your server), but your own exception logger will happily dump the raw string if you pass $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.
  • Testing. Bind a fake in 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.
  • License. The library is LGPL-3.0. Composer-installing and linking it into proprietary code is generally considered fine, but have legal confirm before shipping if your org is strict about copyleft in PHP (some legal teams treat PHP use statements as dynamic linking, others don't).
  • Extension points you'll actually touch: the PSR-16 cache adapter (to use Laravel's store), the PSR-18 client (to use Guzzle / pool requests), and the validation rule (to control fail-open behaviour). The checker itself is thin — you rarely need to fork it.
  • Composer conflicts. 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.
  • Consider a local fallback for the top-N leaked passwords. No library replaces a small in-app blocklist for 123456, password, etc. — those should be rejected instantly without an HTTP round-trip, before isExposed() is ever called.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel