Password Validation in Laravel
A password of 123456 passes required|string just fine – it is a string, and it is present. But that is not the kind of password that belongs in a database. Laravel provides the Password rule class to enforce complexity: minimum length, mixed case, digits, special characters, and breach database checks. This guide covers the Password builder alongside confirmed, current_password, custom messages, and regex-based patterns.
For general validation concepts, see the quick start guide.
The Password Rule Object
The Illuminate\Validation\Rules\Password class builds a set of requirements through a fluent chain:
use Illuminate\Validation\Rules\Password;
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
Minimum 8 characters plus confirmation. For production, this is not enough.
Complexity Methods
Password::min(8)
->letters() // at least one letter
->mixedCase() // upper + lower
->numbers() // at least one digit
->symbols() // at least one special character (!@#$%...)
Each method adds a separate check, and each failed check produces its own error message. The user sees exactly what is missing: “must contain an uppercase letter”, “must contain a number” – not a vague “password too weak”.
// Internal admin panel – strict
Password::min(12)->mixedCase()->numbers()->symbols()
// Public registration – lighter
Password::min(8)->letters()->numbers()
The mixedCase() method requires at least one uppercase and one lowercase letter. This is the “password mixed” requirement that users frequently search for. Each method in the chain is independent – you can combine them in any order, and removing one does not affect the others.
NIST SP 800-63B recommends length as the primary complexity factor. A 20-character passphrase is stronger than 8 characters with mandatory !@#. If following NIST guidance, Password::min(12)->letters()->numbers()->uncompromised() without symbols() and mixedCase() is a reasonable production setup.
uncompromised() – Breach Database Check
Password::min(8)->uncompromised()
Laravel sends the first 5 characters of the SHA-1 hash of the password to the haveibeenpwned.com API and checks if the full hash appears in any known breach. The k-Anonymity model means the actual password never leaves the server.
By default, a password is rejected if it appears even once. Raise the threshold if the default is too strict:
// Reject only if found 3+ times in breach databases
Password::min(8)->uncompromised(3)
The method makes an HTTP request, which slows down tests. Disable it in test environments through Password::defaults().
Full Chain
All methods combined for maximum security:
'password' => [
'required', 'confirmed', 'max:72',
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(),
],
The max:72 is a separate string rule – the Password object handles complexity, max handles length. Why 72? Bcrypt, Laravel’s default hasher, truncates input at 72 bytes. Two passwords that differ only after byte 72 produce the same hash. Always add max:72 to make this explicit. If you use Argon2 (HASH_DRIVER=argon2id), max:255 covers all practical cases.
Password Confirmation
The confirmed rule expects a matching {field}_confirmation field in the request:
$request->validate([
'password' => ['required', 'confirmed', Password::min(8)],
]);
// request must include password_confirmation
HTML form:
<input type="password" name="password">
<input type="password" name="password_confirmation">
The confirmation error is attached to the password field, not to password_confirmation. In Blade:
@error('password')
<span>{{ $message }}</span>
@enderror
To use a custom confirmation field name:
'password' => ['required', 'confirmed:password_repeat'],
More on confirmed vs same in the rules reference.
Checking the Current Password
When changing a password, you need to verify the user knows the current one. The current_password rule compares the input against the authenticated user’s hash:
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
]);
For apps with multiple guards, specify which one:
'current_password' => ['required', 'current_password:admin'],
The rule uses Hash::check() internally. If the user is not authenticated (the guard returns null), the rule fails. Make sure the route is behind the auth middleware.
A practical use beyond password changes – confirming identity before critical actions like changing email or deleting an account:
$request->validate([
'current_password' => ['required', 'current_password'],
'email' => ['required', 'email', Rule::unique('users')->ignore($request->user())],
]);
Password::defaults() – Global Configuration
Repeating Password::min(8)->mixedCase()->numbers() in every Form Request is tedious. Set it once in AppServiceProvider::boot():
use Illuminate\Validation\Rules\Password;
public function boot(): void
{
Password::defaults(function () {
$rule = Password::min(8);
return $this->app->isProduction()
? $rule->mixedCase()->numbers()->uncompromised()
: $rule;
});
}
Production gets strict rules with breach checking. Dev and test get minimum length only, so test passwords like password do not need special characters. The closure runs on each call, so the environment is checked dynamically.
In your rules:
'password' => ['required', 'confirmed', Password::defaults()],
To extend defaults with additional rules:
Password::defaults(function () {
return Password::min(8)
->mixedCase()
->rules([new NotPreviousPassword]);
});
Custom Password Validation with Regex
When the built-in complexity methods do not cover your requirements, add a regex pattern. For example, requiring at least two digits:
'password' => [
'required',
'confirmed',
'regex:/\d.*\d/',
Password::min(8),
],
Or enforcing that special characters come from a specific set:
'password' => [
'required',
'confirmed',
'regex:/[!@#$%^&*]/',
Password::min(8)->letters()->numbers(),
],
Remember that regex with a pipe | in the pattern must use array syntax, not string syntax. See the rules reference for details.
For complex patterns that go beyond regex, build a custom Rule class. A common example: checking that the password does not contain the user’s name or email:
public function after(): array
{
return [
function ($validator) {
$password = $this->input('password');
$email = $this->input('email') ?? $this->user()?->email;
if ($password && $email && false !== stripos($password, explode('@', $email)[0])) {
$validator->errors()->add('password', 'The password must not contain your email username.');
}
},
];
}
Custom Error Messages for Passwords
Default messages are in English and generic. Override them in a Form Request:
public function messages(): array
{
return [
'password.required' => 'Please enter a password.',
'password.confirmed' => 'The passwords do not match.',
'password.min' => 'The password must be at least :min characters.',
];
}
Messages from the Password rule object (letters, mixedCase, numbers, symbols, uncompromised) are customized through language files. In lang/en/validation.php:
'password' => [
'letters' => 'The password must contain at least one letter.',
'mixed' => 'The password must contain both uppercase and lowercase letters.',
'numbers' => 'The password must contain at least one number.',
'symbols' => 'The password must contain at least one special character.',
'uncompromised' => 'This password has appeared in a data breach. Please choose a different one.',
],
Full localization guide in the error messages article.
Validating a Password Hash
Sometimes you need to check a raw password against a stored hash outside of the authentication flow – for instance, confirming a password before an API key rotation:
use Illuminate\Support\Facades\Hash;
if (Hash::check($request->input('password'), $user->password)) {
// password matches the stored hash
}
This is not a validation rule – it is a direct comparison. For validation rules, current_password is the standard approach. Hash::check() is for cases where you need the check in application logic rather than in a rule set.
Do not validate the hash itself – Password::min(8) checks the plaintext input, not the hashed output. A common mistake is hashing the password before validation, which causes the complexity rules to check the ~60-character hash string instead of the original password.
Note that Hash::needsRehash() is useful after login to check if the hashing algorithm has changed since the password was stored. If it returns true, re-hash with the current algorithm:
if (Hash::needsRehash($user->password)) {
$user->update(['password' => $request->input('password')]);
}
This is separate from validation – it happens during authentication, not during password validation rules.
Password Reset Validation
During password reset, current_password is not needed – the user proved their identity via an email token:
class ResetPasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email', 'exists:users'],
'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
];
}
}
No current_password here – authorization happens through the token. Password::defaults() and confirmed still apply. Laravel’s built-in password reset (via Breeze or Fortify) handles the token verification, so the Form Request above only validates the input fields.
Different Password Policies by Role
Admin accounts may need stricter rules than regular users:
class StoreUserRequest extends FormRequest
{
public function rules(): array
{
$passwordRule = 'admin' === $this->input('role')
? Password::min(12)->mixedCase()->numbers()->symbols()->uncompromised()
: Password::defaults();
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'unique:users'],
'role' => ['required', 'in:user,editor,admin'],
'password' => ['required', 'confirmed', $passwordRule],
];
}
}
The alternative is separate Form Requests per role. But when only the password differs, a conditional in rules() is simpler.
OAuth Users and Passwords
Users who register through OAuth (Google, GitHub) do not have a password. The password column stays null:
$user = User::firstOrCreate(
['email' => $socialUser->getEmail()],
['name' => $socialUser->getName(), 'password' => null],
);
If the user later wants to set a password (to log in without OAuth), validate without current_password since there is no current password to check:
public function rules(): array
{
$rules = [
'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
];
if (null !== $this->user()->password) {
$rules['current_password'] = ['required', 'current_password'];
}
return $rules;
}
Generating Secure Passwords
When the application creates a password for the user (invitations, temporary access), use Str::password():
use Illuminate\Support\Str;
$temporaryPassword = Str::password(16);
Default length is 32 characters. Parameters control composition:
Str::password(
length: 20,
letters: true,
numbers: true,
symbols: true,
)
The generated password will pass any Password rules as long as length and composition match. A typical flow: admin creates a user, the system generates a temporary password, sends it by email, and forces a change on first login.
Password Strength Indicator
Server-side validation is mandatory, but showing requirements before submit improves the experience. Two approaches:
Precognition – Laravel validates the field on blur without duplicating logic:
Route::post('/register', [RegisterController::class, 'store'])
->middleware('precognitive');
The frontend gets real-time errors from the same Password rules used on submit. No JavaScript duplication needed.
Client-side check – replicate the checks in JS for instant feedback without a server round-trip. Less reliable (rules can drift), but works offline. Keep server-side validation as the source of truth. A typical indicator shows four levels (weak, fair, good, strong) based on length, digit count, case mix, and special characters – mirroring what the Password rule object checks on the server.
Preventing Password Reuse
Laravel has no built-in password history. Implement it with a table and a custom rule:
// Migration
Schema::create('password_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('password');
$table->timestamp('created_at');
});
Custom rule that checks the last N passwords:
class NotPreviousPassword implements ValidationRule
{
public function __construct(private User $user, private int $count = 5) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$previous = $this->user->passwordHistories()
->latest()
->take($this->count)
->pluck('password');
foreach ($previous as $hash) {
if (Hash::check($value, $hash)) {
$fail("You cannot reuse any of your last {$this->count} passwords.");
return;
}
}
}
}
Wire it into the password change form:
'password' => [
'required', 'confirmed', 'max:72',
Password::defaults(),
new NotPreviousPassword($this->user(), 5),
],
Save the old hash to the history table in the controller or an observer after a successful update.
Bulk Import: Passwords Without Confirmation
When importing users from a CSV or API, confirmed is unnecessary – there is no interactive form:
foreach ($rows as $row) {
$validator = Validator::make($row, [
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', Password::min(8)->letters()->numbers()],
]);
if ($validator->fails()) {
continue;
}
User::create($validator->validated());
}
For temporary passwords that must be changed on first login, relax the rules and flag the user with a must_change_password column. Check this flag in middleware and redirect to the password change form until the user sets a proper password.
Complete Example: Registration
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:60'],
'email' => ['required', 'email:rfc,dns', 'unique:users'],
'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
];
}
public function messages(): array
{
return [
'password.confirmed' => 'The password confirmation does not match.',
];
}
}
Complete Example: Password Change
Three fields: current password, new password, confirmation.
class ChangePasswordRequest extends FormRequest
{
public function rules(): array
{
return [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', 'max:72', Password::defaults()],
];
}
}
class PasswordController extends Controller
{
public function update(ChangePasswordRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->validated('password'),
]);
return back()->with('status', 'Password changed.');
}
}
No manual Hash::make() needed if the User model uses the hashed cast on the password attribute. Eloquent hashes the value automatically on write. This is the recommended approach since Laravel 11.
Testing Password Validation
public function test_registration_rejects_weak_password(): void
{
$response = $this->post('/register', [
'name' => 'Jane Doe',
'email' => '[email protected]',
'password' => 'short',
'password_confirmation' => 'short',
]);
$response->assertSessionHasErrors('password');
}
public function test_mismatched_confirmation_fails(): void
{
$response = $this->post('/register', [
'name' => 'Jane Doe',
'email' => '[email protected]',
'password' => 'SecurePass123!',
'password_confirmation' => 'DifferentPass456!',
]);
$response->assertSessionHasErrors('password');
}
If Password::defaults() includes uncompromised(), tests will hit the haveibeenpwned.com API. Disable it in your TestCase::setUp():
protected function setUp(): void
{
parent::setUp();
Password::defaults(fn () => Password::min(8));
}
Now tests run without external API calls – critical for CI where the service may be slow or unavailable.
Testing password change with current password verification:
public function test_change_requires_correct_current_password(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put('/password', [
'current_password' => 'wrong-password',
'password' => 'NewSecure123!',
'password_confirmation' => 'NewSecure123!',
]);
$response->assertSessionHasErrors('current_password');
}
public function test_successful_password_change(): void
{
$user = User::factory()->create(['password' => 'OldPass123!']);
$this->actingAs($user);
$response = $this->put('/password', [
'current_password' => 'OldPass123!',
'password' => 'NewSecure456!',
'password_confirmation' => 'NewSecure456!',
]);
$response->assertSessionHasNoErrors();
$this->assertTrue(Hash::check('NewSecure456!', $user->fresh()->password));
}
For a full uncompromised() test, create a separate test tagged @group external that runs manually, not in CI:
/** @group external */
public function test_common_password_rejected(): void
{
Password::defaults(fn () => Password::min(8)->uncompromised());
$response = $this->post('/register', [
'name' => 'Test',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertSessionHasErrors('password');
}
Common Mistakes
Hashing before validation – calling Hash::make() on the password before running rules. Password::min(8) then checks the ~60-character hash, not the original password. Always validate first, hash after (or use the hashed model cast).
@error('password_confirmation') instead of @error('password') – the confirmed rule attaches its error to the main field, not the confirmation field. Put the error display under the password field.
Forgetting max:72 with bcrypt – without it, two passwords that differ only after byte 72 produce identical hashes. The user can “change” their password without effect.
Password::defaults() not applied – if the service provider is not registered in bootstrap/providers.php, the defaults closure never runs. Password::defaults() without arguments returns Password::min(8) with no complexity checks.
uncompromised() in offline environments – the method throws an exception when the API is unreachable. Either remove it from defaults or wrap in an environment check.
No hashed cast on the User model – without it, the password is stored in plaintext. Laravel 11+ includes the cast in the default model stub. Verify with php artisan model:show User – the Casts section should list password: hashed. Without the cast, you must call Hash::make() manually before saving.
same:password instead of confirmed – same works but attaches the error to the second field (password_repeat), not to the primary password field. With confirmed, the error is always on the main field, which is easier to handle in Blade.
For conditional password rules (e.g., password required only on create, optional on update), array validation, file uploads, and database rules, see the respective articles. The Form Request article covers prepareForValidation(), after() hooks, and the strings and numbers article has min/max details. For dates, see the dates article.