This document describes how to configure the Password Policy Bundle.
- Configuration File
- Configuration Options
- How It Works
- Examples
- Multiple Entities Configuration
- Events
- Best Practices
- Demo Projects
- Configuration examples reference
The bundle configuration is defined in config/packages/nowo_password_policy.yaml:
nowo_password_policy:
entities:
App\Entity\User:
password_field: password
password_history_field: passwordHistory
passwords_to_remember: 5
expiry_days: 60
reset_password_route_name: user_reset_password
notified_routes:
- user_profile
- user_settings
excluded_notified_routes:
- user_logout
expiry_listener:
priority: 0
redirect_on_expiry: false
flash_strategy: once_per_session
flash_interval_minutes: 30
error_msg:
text:
title: 'Your password expired.'
message: 'You need to change it'
type: 'error'
enable_logging: true
log_level: infoEach entity that implements HasPasswordPolicyInterface must be configured under entities:
| Option | Type | Default | Description |
|---|---|---|---|
password_field |
string |
'password' |
The name of the password field in the entity. This field will be monitored for changes to track password history. |
password_history_field |
string |
'passwordHistory' |
The name of the password history collection field in the entity. This should be a OneToMany or ManyToMany relationship to a PasswordHistoryInterface entity. |
passwords_to_remember |
int |
3 |
The maximum number of previous passwords to keep in history. When this limit is exceeded, the oldest passwords are automatically removed. |
expiry_days |
int |
90 |
Number of days after which a password expires. After this period, users will be notified or redirected to change their password. |
reset_password_route_name |
string |
required | Fallback route name used when generating the reset URL (required for backward compatibility). When reset_password_route_pattern is set and resolves a name from the router, that name is used instead. |
reset_password_route_pattern |
string | null |
null |
Optional pattern to select the reset route name from the application router: first match in alphabetical order among registered route names. Same syntax as entries in notified_routes (see Route name patterns). If unset or no match, reset_password_route_name is used. |
notified_routes |
array |
[] |
Entries where expiry is enforced (literals or patterns; see Route name patterns). The listener compares the current request route name (_route) against each entry. |
excluded_notified_routes |
array |
[] |
Entries where expiry handling is skipped if the current route matches any of them (literals or patterns). Use for login, logout, API auth, or routes that would cause redirect loops. |
| Option | Type | Default | Description |
|---|---|---|---|
priority |
int |
0 |
Priority of the expiry listener. Higher values mean the listener runs earlier. Default is 0. |
lock_route |
string |
- | (Deprecated) Route to redirect when password is expired. Use redirect_on_expiry and reset_password_route_name instead. |
redirect_on_expiry |
bool |
false |
If true, automatically redirects users to the reset_password_route_name when their password expires. If false, only shows a flash message without redirecting. |
flash_strategy |
string |
'always' |
How often the expiry flash is added. See Flash notification strategies. |
flash_interval_minutes |
int |
30 |
Minutes between flashes when flash_strategy is interval. Minimum is 1. |
flash_throttle_storage |
string |
'session' |
Backend for throttle state: session or cache. Use cache with Redis/Memcached for FrankenPHP workers or Kubernetes multi-pod. |
flash_throttle_cache_service |
string |
'cache.app' |
Symfony cache pool service id when flash_throttle_storage is cache. |
flash_throttle_cache_ttl |
int |
86400 |
TTL (seconds) for cache entries. For once_per_session, align with session lifetime. |
flash_throttle_storage_service |
string | null |
null |
Custom service implementing ExpiryFlashThrottleStorageInterface. Overrides built-in session/cache backends. |
error_msg.text.title |
string |
- | Error message title. Can be a string or translation key. Supports translation keys. |
error_msg.text.message |
string |
- | Error message body. Can be a string or translation key. Supports translation keys. |
error_msg.type |
string |
'error' |
Flash message type. Common values: "error", "warning", "info", "success". This determines the CSS class and styling of the flash message. |
| Option | Type | Default | Description |
|---|---|---|---|
enable_logging |
bool |
true |
Enable or disable logging for password policy events. When enabled, important events like password expiry, password changes, and reuse attempts will be logged using Symfony Logger. |
log_level |
string |
'info' |
Logging level for password policy events. Valid values: "debug", "info", "notice", "warning", "error". All password policy events (expiry detection, password changes, reuse attempts) will be logged at this level. |
| Option | Type | Default | Description |
|---|---|---|---|
enable_cache |
bool |
false |
Enable caching for password expiry checks. When enabled, expiry status is cached per user to improve performance. Cache is automatically invalidated when password changes. Requires Symfony Cache component. |
cache_ttl |
int |
3600 |
Cache time-to-live in seconds. Default is 3600 (1 hour). Only used when enable_cache is true. The cache key includes user ID, class, and password change timestamp, so it's automatically invalidated when password changes. |
The bundle uses Doctrine lifecycle events (onFlush) to:
- Track password changes
- Store old passwords in history
- Update
passwordChangedAttimestamp - Limit history to configured number of passwords
Validation cost: On password change, the PasswordPolicy validator compares the new plain password against each stored hash (password_verify / Symfony hasher). Keep passwords_to_remember low (default 3).
Extension detection (detect_password_extensions: true): the service strips allowed single-character and numeric (0–999) prefixes/suffixes, deduplicates candidate base passwords, then verifies each candidate against history. Work is bounded by password length and history size—not by scanning 0–999 on every hash. Leave extension detection disabled unless required; each verification still invokes the password hasher.
The expiry listener checks on each request:
- Calculates days since last password change
- Compares with configured
expiry_days - Shows flash message with configured text (according to
flash_strategy) - If
redirect_on_expiryistrue, redirects to the resolved reset route (seereset_password_route_patternandreset_password_route_name)
Note: By default (flash_strategy: always), the flash is re-added on every locked-route request after the previous message was consumed by the layout. To show it only once per session or on a timer, change flash_strategy. To enable automatic redirection, set redirect_on_expiry: true in the configuration.
| Value | Behaviour |
|---|---|
always |
Adds the flash whenever the user hits a locked route and the message is not already in the flash bag (default; same as before v1.2.0). |
once_per_session |
Adds the flash at most once per session (recommended for most apps). Resets on logout or session expiry. |
interval |
Re-adds the flash only after flash_interval_minutes have passed since the last time it was shown in this session. |
never |
Never adds a flash. Logging, PasswordExpiredEvent, and optional redirect still run. |
Example — show the message once per login session:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: sessionExample — remind every 15 minutes while the password remains expired:
nowo_password_policy:
expiry_listener:
flash_strategy: interval
flash_interval_minutes: 15
flash_throttle_storage: sessionAll copy-paste examples also live in docs/examples/expiry-flash-and-cache.yaml.
always — default; flash on every locked route after the previous one was consumed:
nowo_password_policy:
expiry_listener:
flash_strategy: alwaysonce_per_session — at most one flash per user/session window:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: sessioninterval — flash again only after flash_interval_minutes:
nowo_password_policy:
expiry_listener:
flash_strategy: interval
flash_interval_minutes: 30
flash_throttle_storage: sessionnever — no flash; use with redirect or custom UX via PasswordExpiredEvent:
nowo_password_policy:
expiry_listener:
flash_strategy: never
redirect_on_expiry: trueSingle node, local dev, or when Symfony sessions are already stored in Redis/Memcached via framework.session.handler_id:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: sessionRecommended for FrankenPHP workers and Kubernetes multi-pod.
config/packages/cache.yaml:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'config/packages/nowo_password_policy.yaml:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: cache
flash_throttle_cache_service: cache.app
flash_throttle_cache_ttl: 86400Environment (.env):
REDIS_URL=redis://redis:6379config/packages/cache.yaml:
framework:
cache:
app: cache.adapter.memcached
default_memcached_provider: '%env(MEMCACHED_URL)%'config/packages/nowo_password_policy.yaml:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: cache
flash_throttle_cache_service: cache.app
flash_throttle_cache_ttl: 86400Environment (.env):
MEMCACHED_URL=memcached://memcached:11211config/packages/cache.yaml:
framework:
cache:
pools:
password_policy.flash_throttle:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'config/packages/nowo_password_policy.yaml:
nowo_password_policy:
expiry_listener:
flash_strategy: interval
flash_interval_minutes: 15
flash_throttle_storage: cache
flash_throttle_cache_service: cache.password_policy.flash_throttle
flash_throttle_cache_ttl: 86400config/packages/cache.yaml:
framework:
cache:
pools:
password_policy.flash_throttle:
adapter: cache.adapter.memcached
provider: '%env(MEMCACHED_URL)%'config/packages/nowo_password_policy.yaml:
nowo_password_policy:
expiry_listener:
flash_strategy: interval
flash_interval_minutes: 15
flash_throttle_storage: cache
flash_throttle_cache_service: cache.password_policy.flash_throttle
flash_throttle_cache_ttl: 86400Implement Nowo\PasswordPolicyBundle\Service\ExpiryFlash\ExpiryFlashThrottleStorageInterface:
config/services.yaml:
services:
App\Security\ExpiryFlashThrottleStorage:
autowire: trueconfig/packages/nowo_password_policy.yaml:
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage_service: App\Security\ExpiryFlashThrottleStorageWhen flash_throttle_storage_service is set, flash_throttle_storage and flash_throttle_cache_* are ignored.
Separate from flash throttle: caches isPasswordExpired() per user via cache.app (wired automatically by the extension).
Redis:
# config/packages/cache.yaml
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
# config/packages/nowo_password_policy.yaml
nowo_password_policy:
enable_cache: true
cache_ttl: 3600Memcached:
framework:
cache:
app: cache.adapter.memcached
default_memcached_provider: '%env(MEMCACHED_URL)%'
nowo_password_policy:
enable_cache: true
cache_ttl: 3600Filesystem — single pod / dev only:
framework:
cache:
app: cache.adapter.filesystem
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: cache
flash_throttle_cache_service: cache.appAPCu — single server, in-process memory only:
framework:
cache:
app: cache.adapter.apcu
nowo_password_policy:
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: cache
flash_throttle_cache_service: cache.app# config/packages/cache.yaml
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
# config/packages/nowo_password_policy.yaml
nowo_password_policy:
entities:
App\Entity\User:
expiry_days: 90
reset_password_route_name: user_reset_password
notified_routes:
- app_dashboard
excluded_notified_routes:
- login
- logout
- user_reset_password
expiry_listener:
flash_strategy: once_per_session
flash_throttle_storage: cache
flash_throttle_cache_service: cache.app
flash_throttle_cache_ttl: 86400
redirect_on_expiry: false
enable_cache: true
cache_ttl: 3600The listener deduplicates flashes within a single request using request attributes (safe with FrankenPHP workers). For once_per_session and interval, throttle state must be shared across workers/pods when more than one PHP process serves traffic.
| Deployment | Recommended flash_throttle_storage |
Recommended cache adapter |
|---|---|---|
| Single pod / dev | session |
— |
| FrankenPHP worker mode | cache |
Redis or Memcached |
| Kubernetes (multiple pods) | cache |
Redis or Memcached |
| Sessions already in Redis | session or cache |
Either works if shared |
If flash_throttle_storage: cache is set but flash_throttle_cache_service (default cache.app) is missing, the container fails at compile time with a clear configuration error.
Each entry in notified_routes, excluded_notified_routes, and optional reset_password_route_pattern can be:
- Literal — exact match on the Symfony route name (same as before).
- Glob — if the entry contains
*or?, matching uses PHPfnmatch()against the route name (e.g.admin_*,app_*_show). - PCRE — if the entry starts and ends with the same delimiter (
~,#, or/), it is passed topreg_match()against the route name (e.g.~^app_operator\.~for routes likeapp_operator.dashboard).
Evaluation order in the listener: the request must match a notified entry (isLockedRoute) before expiry logic runs. If the route is also excluded, expiry actions (flash, redirect) are not applied. Exhaustive listing of routes is no longer required when a prefix or naming convention applies.
Reset route resolution (reset_password_route_pattern): when set, the bundle loads all route names from RouterInterface, sorts them alphabetically, and picks the first name that matches the pattern. If none match, or the router is unavailable, the URL is generated with reset_password_route_name.
The expiry listener evaluates notified_routes on every main HTTP request that has a named route (_route). Keep the configuration tight so matching stays fast and behaviour stays predictable.
-
Prefer literal route names — use exact Symfony route names (e.g.
admin_dashboard,user_profile) whenever you know the target routes. Literals are the cheapest match (string equality). -
Use globs and regex only for real prefixes — reserve
fnmatchglobs (e.g.admin_*) or delimited PCRE (e.g.~^app_admin\.~) for groups of routes that genuinely share a naming convention. Avoid broad patterns such as*or~.*~that match almost every route; they force pattern matching on every request and make exclusions harder to reason about. -
Keep
notified_routesminimal — list only routes where an expired password should trigger expiry handling (flash and optional redirect). Do not add routes “just in case”; emptynotified_routesmeans expiry is never enforced on HTTP requests for that entity. -
Use
excluded_notified_routesfor auth and escape hatches — even when a route matchesnotified_routes, exclusions skip expiry actions. Always exclude routes such as login, logout, password reset, and stateless API endpoints so users can authenticate, sign out, recover access, or call APIs without redirect loops or blocked flows.
Example (literals + targeted exclusions):
nowo_password_policy:
entities:
App\Entity\User:
expiry_days: 90
reset_password_route_name: user_reset_password
notified_routes:
- user_dashboard
- user_settings
- user_profile
excluded_notified_routes:
- login
- logout
- user_reset_password
- api_login
- api_logoutWhen many admin routes share a prefix, a single glob in notified_routes plus explicit exclusions is acceptable:
notified_routes:
- admin_* # only when admin routes truly share this prefix
excluded_notified_routes:
- admin_login
- admin_logout
- admin_reset_passwordSee also Best Practices for cache and logging recommendations.
Important: The bundle uses Doctrine onFlush event. Any entity changes after password history recalculation will not be persisted.
When enable_cache is true, the bundle caches password expiry status per user to improve performance:
- Cache Key: Includes user ID, entity class, and password change timestamp
- Automatic Invalidation: Cache is automatically invalidated when a password changes
- TTL: Configurable via
cache_ttl(default: 3600 seconds / 1 hour) - Requirements: Requires Symfony Cache component (
cache.appservice)
Benefits:
- Reduces database queries on each request
- Improves response time for applications with many concurrent users
- Cache automatically stays in sync with password changes
When to Enable:
- Applications with high traffic
- Multiple password expiry checks per request
- When performance is a concern
When to Disable:
- Development environments
- When real-time expiry status is critical
- If cache service is not available
nowo_password_policy:
entities:
App\Entity\User:
reset_password_route_name: user_reset_password
expiry_listener:
redirect_on_expiry: false
enable_logging: true
log_level: infonowo_password_policy:
entities:
App\Entity\User:
password_field: password
password_history_field: passwordHistory
passwords_to_remember: 10
expiry_days: 30
reset_password_route_name: user_reset_password
notified_routes:
- user_dashboard
- user_profile
excluded_notified_routes:
- user_logout
- api_login
App\Entity\Admin:
passwords_to_remember: 20
expiry_days: 15
reset_password_route_name: admin_reset_password
notified_routes:
- admin_dashboard
expiry_listener:
priority: 10
redirect_on_expiry: true
error_msg:
text:
title: 'Password Expired'
message: 'Your password has expired. Please change it to continue.'
type: 'warning'
enable_logging: true
log_level: info
enable_cache: true
cache_ttl: 3600The bundle supports configuring multiple entities with different password policies. This is useful when you have different user types (e.g., regular users and administrators) that require different password policies.
-
Unique Routes: Each entity must have a unique
reset_password_route_name. The bundle validates this at configuration time and will throw an error if duplicates are found. -
Duplicate
notified_routesacross entities: Literals must not be duplicated unless the same literal appears inexcluded_notified_routesfor both entities. Entries that are glob or regex patterns (wildcards or delimited PCRE) are not checked for duplicate literals across entities, because overlap can only be approximated at runtime. -
Route Conflicts: While
notified_routescan overlap between entities, it's recommended to use entity-specific routes or properly configureexcluded_notified_routesto avoid conflicts. -
Entity Matching: The bundle automatically matches the current authenticated user to the correct entity configuration based on the user's class.
nowo_password_policy:
entities:
# Regular users
App\Entity\User:
passwords_to_remember: 5
expiry_days: 90
reset_password_route_name: user_reset_password # Must be unique
notified_routes:
- user_dashboard
- user_profile
excluded_notified_routes:
- user_logout
- user_reset_password
# Administrators with stricter policy
App\Entity\Admin:
passwords_to_remember: 10
expiry_days: 30
reset_password_route_name: admin_reset_password # Must be unique
notified_routes:
- admin_dashboard
- admin_settings
excluded_notified_routes:
- admin_logout
- admin_reset_password
# API users with different policy
App\Entity\ApiUser:
passwords_to_remember: 3
expiry_days: 180
reset_password_route_name: api_reset_password # Must be unique
notified_routes: []
excluded_notified_routes:
- api_login
- api_logout
expiry_listener:
priority: 0
redirect_on_expiry: false
enable_logging: true
log_level: info
enable_cache: true
cache_ttl: 3600The bundle automatically validates:
- ✅ Each entity has a unique
reset_password_route_name - ✅ No duplicate
notified_routesacross entities (warns if found) - ✅ All route names are valid strings
- ✅ Entity classes exist and implement
HasPasswordPolicyInterface
If validation fails, a ConfigurationException is thrown with a clear error message indicating which entities have conflicts.
The bundle dispatches custom Symfony events that you can listen to for extending functionality. For complete documentation on events, including detailed examples and integration patterns, see Events Documentation.
Quick Reference:
PasswordExpiredEvent: Dispatched when a password expiry is detectedPasswordHistoryCreatedEvent: Dispatched when a password history entry is createdPasswordChangedEvent: Dispatched when a password is changedPasswordReuseAttemptedEvent: Dispatched when a user attempts to reuse an old password
See Events Documentation for complete details, examples, and best practices.
- Set appropriate expiry days: Balance security with user experience
- Keep
notified_routesminimal: Enforce expiry only on routes where users must change an expired password (see Route configuration recommendations) - Prefer literal route names: Use exact route names in
notified_routes; reserve globs and regex for genuine shared prefixes - Exclude auth and API routes: Add login, logout, password reset, and API routes to
excluded_notified_routesto avoid redirect loops and blocked flows - Use meaningful route names: Make configuration self-documenting
- Enable redirect on expiry: Set
redirect_on_expiry: trueto automatically redirect users to password reset page - Validate route names: Ensure
reset_password_route_nameand all route names innotified_routesexist in your application - Enable logging: Use
enable_logging: trueand configure appropriatelog_levelfor debugging and auditing - Enable cache for performance: Use
enable_cache: truein production to improve performance. Cache is automatically invalidated on password changes. - Unique routes for multiple entities: When configuring multiple entities, ensure each has a unique
reset_password_route_nameto avoid conflicts. - Listen to events: Use custom events to extend functionality (notifications, external logging, etc.)
- Test expiry behaviour: Ensure expiry works correctly in your application flow
- Use Symfony Flex Recipe: Let Flex automatically create the configuration file
- Test with demos: Use the included demo projects to understand bundle behaviour
- Multi-pod / FrankenPHP: Use
flash_throttle_storage: cachewith Redis or Memcached foronce_per_sessionandinterval(see complete examples)
| Example | Location |
|---|---|
| All flash strategies + Redis/Memcached/session/custom | docs/examples/expiry-flash-and-cache.yaml |
| Inline documentation | Expiry flash and throttle storage — complete examples |
| Demo (commented snippets) | demo/symfony8/config/packages/nowo_password_policy.yaml and cache.yaml |
The bundle includes a demo project for Symfony 8 that demonstrates:
- Complete CRUD interface for user management
- Password change functionality with validation
- Visual password expiry status indicators
- Password history tracking
- Commented configuration examples for expiry flash strategies and cache backends (Redis, Memcached)
- Database setup with migrations and fixtures
See demo/README.md for more information on running the demos.