Laravel Validation: Custom Rules and Messages
Validation is the first line of defence for any web application. Bad data leads to broken logic, security vulnerabilities, and corrupted databases. Laravel's validation system is one of the most elegant in any framework — but most developers only scratch the surface of what it can do. In this guide we'll go from the basics of inline validation all the way to custom Rule objects, Form Requests, and conditional logic that handles real-world complexity.
Laravel provides 90+ built-in validation rules out of the box, automatic redirect-with-errors on failure, seamless integration with Blade templates, and a clean API for adding your own domain-specific rules. You rarely need to reach for a third-party library.
Built-in Validation Rules
The simplest way to validate incoming data is to call $request->validate() inside a controller method. If validation fails Laravel automatically redirects the user back with the errors flashed to the session — no extra code needed.
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'email' => 'required|email|unique:users,email',
'age' => 'required|integer|min:18|max:120',
'website' => 'nullable|url',
'role' => 'required|in:admin,editor,viewer',
'password' => 'required|string|min:8|confirmed',
'avatar' => 'nullable|image|mimes:jpg,png,webp|max:2048',
'tags' => 'nullable|array|max:5',
'tags.*' => 'string|max:30',
]);
User::create($validated);
return redirect()->route('users.index')->with('success', 'User created.');
}
Here is a quick reference of the most commonly used built-in rules:
| Rule | Purpose | Example |
|---|---|---|
required | Field must be present and non-empty | 'name' => 'required' |
string | Must be a string | 'bio' => 'string' |
integer | Must be an integer | 'age' => 'integer' |
numeric | Must be numeric (int or float) | 'price' => 'numeric' |
email | Must be a valid email address | 'email' => 'email' |
url | Must be a valid URL | 'website' => 'url' |
min:n / max:n | Length or value bounds | 'password' => 'min:8' |
unique:table,col | Must not exist in database | 'email' => 'unique:users' |
exists:table,col | Must exist in database | 'category_id' => 'exists:categories,id' |
in:a,b,c | Must be one of the listed values | 'status' => 'in:active,inactive' |
confirmed | Must have matching _confirmation field | 'password' => 'confirmed' |
image | Must be an image file | 'avatar' => 'image' |
array | Must be an array | 'tags' => 'array' |
date | Must be a valid date | 'dob' => 'date' |
regex:pattern | Must match a regex | 'phone' => 'regex:/^[0-9]{10}$/' |
Custom Error Messages
Default Laravel error messages are sensible but generic. For a polished user experience you'll want to override them with messages that are specific to your domain.
Inline Message Overrides
Pass a second array to validate() with keys in the format field.rule:
$request->validate(
[
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
'age' => 'required|integer|min:18',
],
[
'email.required' => 'We need your email address to create your account.',
'email.unique' => 'That email is already registered. Try logging in instead.',
'password.min' => 'Your password must be at least 8 characters long.',
'password.confirmed'=> 'The passwords you entered do not match.',
'age.min' => 'You must be at least 18 years old to register.',
]
);
Custom Attribute Names
By default Laravel uses the field name in error messages. Pass a third array to rename attributes to something human-friendly:
$request->validate(
['dob' => 'required|date|before:today'],
['dob.required' => 'Please enter your date of birth.'],
['dob' => 'date of birth'] // Used in default messages: "The date of birth field is required."
);
Global Translations
For app-wide overrides, edit lang/en/validation.php. The attributes key at the bottom lets you map field names globally without repeating yourself across controllers:
// At the bottom of the file:
'attributes' => [
'email' => 'email address',
'dob' => 'date of birth',
'phone_number'=> 'phone number',
'zip_code' => 'ZIP code',
],
Form Request Classes
For any non-trivial form, inline validation in the controller quickly gets messy. Form Requests are dedicated classes that encapsulate your validation logic, keeping controllers slim and making rules reusable and testable.
Generate the Form Request
php artisan make:request StoreUserRequest — creates app/Http/Requests/StoreUserRequest.php.
Define the authorize() method
Return true to allow all users, or add policy/role checks here. Returning false gives a 403.
Define the rules() method
Return your validation rules array — the same format as inline validation.
Type-hint it in the controller
Replace Request $request with StoreUserRequest $request. Validation runs automatically before your controller method is called.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
// Only authenticated admins can create users
return $this->user()?->isAdmin() ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(8)
->mixedCase()
->numbers()
->symbols()],
'role' => ['required', 'in:admin,editor,viewer'],
'avatar' => ['nullable', 'image', 'max:2048'],
];
}
public function messages(): array
{
return [
'email.unique' => 'That email address is already taken.',
'password.confirmed' => 'Passwords do not match.',
];
}
public function attributes(): array
{
return [
'email' => 'email address',
];
}
}
use App\Http\Requests\StoreUserRequest;
public function store(StoreUserRequest $request)
{
// $request->validated() returns only the validated fields
$user = User::create($request->validated());
return redirect()->route('users.show', $user)->with('success', 'User created!');
}
Custom Validation Rules (Rule Objects)
When none of the 90+ built-in rules fit your requirement, create a Rule object. This is ideal for rules you'll reuse across multiple forms — like validating a phone number format, checking a VAT number, or enforcing a business-specific password policy.
php artisan make:rule StrongPassword
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class StrongPassword implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (strlen($value) < 8) {
$fail('The :attribute must be at least 8 characters.');
return;
}
if (!preg_match('/[A-Z]/', $value)) {
$fail('The :attribute must contain at least one uppercase letter.');
return;
}
if (!preg_match('/[0-9]/', $value)) {
$fail('The :attribute must contain at least one number.');
return;
}
if (!preg_match('/[\W_]/', $value)) {
$fail('The :attribute must contain at least one special character.');
}
}
}
Use it just like any built-in rule by instantiating it in your rules array:
use App\Rules\StrongPassword;
$request->validate([
'password' => ['required', 'confirmed', new StrongPassword()],
]);
Use a Rule class when you need the rule in multiple places. Use a closure (shown next) for one-off validation that lives in a single controller or form request.
Closure-Based Custom Rules
For a quick one-off rule that doesn't warrant its own class, pass a closure directly into the rules array:
use Illuminate\Validation\Validator;
$request->validate([
'username' => [
'required',
'string',
'max:30',
function (string $attribute, mixed $value, Closure $fail) {
if (str_contains(strtolower($value), 'admin')) {
$fail("The {$attribute} cannot contain the word 'admin'.");
}
if (!preg_match('/^[a-z0-9_]+$/', $value)) {
$fail("The {$attribute} may only contain lowercase letters, numbers, and underscores.");
}
},
],
]);
Conditional Validation
Real-world forms have fields that are only required depending on other fields. Laravel handles this elegantly with required_if, required_with, and the sometimes() method on the Validator facade.
$request->validate([
'payment_method' => 'required|in:card,bank_transfer,crypto',
// Only required when paying by card
'card_number' => 'required_if:payment_method,card|digits:16',
'card_expiry' => 'required_if:payment_method,card|date_format:m/y',
'card_cvv' => 'required_if:payment_method,card|digits:3',
// Required when either field is present
'bank_name' => 'required_with:bank_account|string|max:100',
'bank_account' => 'required_with:bank_name|string|max:30',
// Required unless another field has a specific value
'shipping_address'=> 'required_unless:delivery_type,digital',
]);
For complex conditional logic use Validator::make() with sometimes():
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($request->all(), [
'country' => 'required|string|size:2',
'zip' => 'required|string',
]);
// Add extra rule only for US addresses
$validator->sometimes('zip', 'digits:5', function ($input) {
return $input->country === 'US';
});
// Add extra rule only for UK addresses
$validator->sometimes('zip', 'regex:/^[A-Z]{1,2}[0-9R][0-9A-Z]? [0-9][ABD-HJLNP-UW-Z]{2}$/i', function ($input) {
return $input->country === 'GB';
});
$validator->validate();
Laravel validation flow: request → Form Request (authorize + rules) → controller → response
Advanced Techniques
Stop on First Failure
By default Laravel runs all rules and returns all errors at once. Use stopOnFirstFailure() on a Validator instance to halt after the first failing rule:
$validator = Validator::make($request->all(), $rules);
$validator->stopOnFirstFailure()->validate();
Array Validation
Validating each element of a submitted array uses the * wildcard:
$request->validate([
'items' => 'required|array|min:1|max:20',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.quantity' => 'required|integer|min:1|max:100',
'items.*.note' => 'nullable|string|max:200',
]);
After Validation Hooks
Add custom logic that runs after all rules pass using after(). Useful for cross-field checks that span multiple tables:
$validator = Validator::make($request->all(), $rules);
$validator->after(function ($validator) use ($request) {
$start = Carbon::parse($request->start_date);
$end = Carbon::parse($request->end_date);
if ($end->lessThanOrEqualTo($start)) {
$validator->errors()->add('end_date', 'End date must be after start date.');
}
});
$validator->validate();
Key Takeaways
- Use
$request->validate()for simple forms in controllers - Move validation to Form Request classes as soon as rules get complex
- Create Rule objects for reusable domain-specific validation
- Use closures for quick one-off rules inside a single form
- Always use
$request->validated()(notall()) to safely mass-assign data - Leverage
required_if,required_with, andsometimes()for conditional logic - Use
after()hooks for cross-field business rules that span multiple values
