nowo-tech/password-policy-bundle
A Symfony bundle for enforcing configurable password policies, including length and complexity rules, expiration, reuse prevention, and customizable validation messages. Integrates with Symfony's validator and security components to help keep user credentials secure with minimal setup.
Reality check first: this is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no auto-discovery for Laravel, no config/password-policy.php, and no Facade. composer require nowo-tech/password-policy-bundle inside a Laravel app will install files you cannot use without a bridge. Decide up front which of these you are doing:
Minimal steps (Symfony context):
composer require nowo-tech/password-policy-bundle
Register the bundle in config/bundles.php (Symfony 6.4/7.x/8.x use the same array-return format):
return [
// ...
NowoTech\PasswordPolicyBundle\PasswordPolicyBundle::class => ['all' => true],
];
Then do not guess the YAML keys — dump them:
php bin/console config:dump-reference password_policy
That command is your source of truth for the config tree; the bundle's DI extension is the authority, not the README. If the alias is not password_policy, run php bin/console debug:container --tag=container.service_subscriber or grep the extension class for getAlias().
Where to look first, in this order:
src/DependencyInjection/*Extension.php — the config tree and every tunable (history depth, expiry days, minimum rules).src/Validator/Constraints/ — the actual constraint classes and their validatedBy() validators. This is the behavioural core.src/Resources/config/services*.php|yaml — service IDs and aliases. These IDs are your extension points.docs/ or README — sanity check only; verify every claim against the code, since the package has 0 stars and 0 dependents and has not been battle-tested by strangers.First use case to wire: "user changes password → reject if it matches their last N passwords." That single flow exercises validation, hashing, and persistence and will tell you within an hour whether the bundle fits your app or whether you should port the logic.
// Illustrative: the concrete constraint name must be confirmed from src/Validator/Constraints
use NowoTech\PasswordPolicyBundle\Validator\Constraints as PasswordPolicy;
final class ChangePasswordInput
{
#[PasswordPolicy\PasswordPolicy] // options from config:dump-reference
public string $plainPassword = '';
}
Laravel-day-to-day equivalent, if you are porting rather than installing:
// app/Rules/NotInPasswordHistory.php
final class NotInPasswordHistory implements ValidationRule
{
public function __construct(private int $depth = 5) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$recent = PasswordHistory::query()
->where('user_id', auth()->id())
->latest()
->limit($this->depth)
->pluck('password_hash');
foreach ($recent as $hash) {
if (Hash::check($value, $hash)) {
$fail('This password was used recently.');
return;
}
}
}
}
Pattern 1 — Constraint on a DTO, not the Eloquent/Doctrine entity. Validate a form-bound DTO (ChangePasswordInput) and only hash + persist after $form->isValid(). Keeps the constraint out of your persistence layer and makes the rule trivially unit-testable without a database.
Pattern 2 — Record history on every successful hash write. The single most common integration bug is writing history in the controller for one flow and forgetting the registration flow. Put the write in one place:
// Symfony, illustrative
final readonly class PasswordRecorder
{
public function __construct(
private PasswordHasherInterface $hasher,
private EntityManagerInterface $em,
private ClockInterface $clock,
) {}
public function record(User $user, string $plain): void
{
$user->setPassword($this->hasher->hashPassword($user, $plain));
$user->setPasswordChangedAt($this->clock->now());
$this->em->persist(new PasswordHistory($user, $user->getPassword()));
}
}
Note it stores the hash, never the plaintext, and uses the same PasswordHasherInterface that Hash::check / isPasswordValid() will later use — mismatch here is silent and total (every history check returns "no match", and the policy appears to do nothing).
Pattern 3 — Expiry as a gate, not as a validator. Expiry is not a property of the new password, so do not model it as a constraint. Model it as an authenticator/subscriber check that sets a "must change password" flag or redirects to the change-password route. In Laravel that is a middleware plus a password_changed_at column:
if (auth()->check() && auth()->user()->password_changed_at?->addDays(config('auth.password_expiry_days'))->isPast()) {
return redirect()->route('password.expired');
}
Pattern 4 — Centralise the policy config in one place. The bundle's YAML and your app's config/auth.php/config/password-policy.php must not both define "depth = 5". Pick one and inject it. In Laravel, bind it:
$this->app->bind(PasswordPolicy::class, fn () => new PasswordPolicy(
historyDepth: config('password-policy.history_depth'),
expiryDays: config('password-policy.expiry_days'),
));
Pattern 5 — Override services, don't fork. If the shipped history repository does not fit (e.g. you store history in a json column on users instead of a table), alias the bundle's service ID to your own class rather than vendoring a patch. Confirm IDs with php bin/console debug:container --show-private | grep -i password. Preserving this is the difference between a maintainable integration and a fork you now own.
Pattern 6 — Test with a frozen clock. Expiry logic that calls new \DateTimeImmutable() is untestable and drifts across DST. Symfony 6.2+ ships Symfony\Component\Clock\ClockInterface and MockClock; inject it. Any bundle-internal code that hardcodes time() is a red flag and a place to wrap with your own decorator.
Pattern 7 — Rehash-on-login vs. history comparison. If your authenticator calls needsRehash() and rehashes after a successful login, the stored hash changes and the corresponding history rows no longer Hash::check() the old passwords. Two safe options: (a) also record the rehashed value into history at rehash time, or (b) do the history comparison against the hash that was valid before rehash. Decide explicitly; the naive implementation quietly degrades history to depth 0.
Pattern 8 — API tokens and machine users. Password expiry must not apply to client_credentials grants, personal access tokens, or service accounts. Scope the expiry check to interactive session logins, or you will page someone at 3am when every integration breaks at day 91.
composer.json's require.php, not either statement, before you assume 8.1 works."nowo-tech/password-policy-bundle": "1.x.y"), commit composer.lock, and write your own integration test around the change-password flow. A composer update here is a real risk.2026-10-21 release date is in the future relative to most tooling's idea of "now". If your CI or Composer audit chokes on it, check system clocks and package-manager mirror state before assuming the tag is malicious.extra.laravel.providers key. Installing it in a Laravel app adds a dead dependency and a composer audit surface for no benefit. If you only want the behaviour, port it; if you want the code, read it and write ~150 lines of your own rules.Hash::check against every history row is O(depth) bcrypt operations. At depth 5 with cost: 12 that is ~1.5s of CPU on the password change request. Acceptable; do not put it on a hot path, and never run it in a while loop over all history.BCRYPT_ROUNDS, ARGON_MEMORY_COST, or the driver between app versions means old history rows will Hash::check() correctly (the cost is encoded in the hash) — good — but a driver switch from bcrypt to argon2id means Hash::check throws RuntimeException on old rows. Catch it and treat as "no match" rather than 500ing the password-change form.php bin/console debug:validator App\Dto\ChangePasswordInput. If the constraint is not listed, the class is not loaded / not in validation.yaml's mapped paths. In Laravel, verify the rule is actually returned by rules(), not just imported.password_changed_at as UTC and compare with a ClockInterface/now() in UTC. Comparing a local-time column against now() gives you a policy that expires 1–12 hours early or late depending on the user's zone.composer says semver-compatible.src/Resources/public/build or vendor copies. If you need a tweak, prefer a service alias / decorator / compiler pass in your app; that survives the next composer update.How can I help you explore Laravel packages today?