🛡️ Validation Rules and Custom Validators
Validation is the checkpoint that keeps bad data out of your database. This lesson takes you from the built-in rule catalog through conditional and dynamic rules, the fluent Password and Rule builders, and finally to writing your own reusable ValidationRule classes with fully customized messages.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Apply Laravel's built-in rules across strings, numbers, dates, arrays, files, and the database
- Validate nested arrays with the
*wildcard notation - Add rules conditionally with
sometimes,required_if, and dynamicrules()logic - Use the fluent
PasswordandRulebuilders for complex constraints - Write custom rules as
ValidationRuleclasses and inline closures - Customize error messages at the call, request, and translation-file levels
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Write a custom ValidationRule and combine it with conditional rules on a real form.
In This Lesson
Why Validate?
Validation is the security checkpoint for your application's data. Just as airport security stops prohibited items at the gate, validation stops invalid or malicious input before it reaches your business logic and database. Data that passes is trustworthy; data that fails never gets the chance to cause damage.
⚠️ Server-side validation is non-negotiable
HTML5 attributes like required and JavaScript checks are a nice convenience, but a determined user can bypass them entirely with a crafted request. Every rule that matters must run on the server.
Two Ways to Validate
Laravel offers two everyday entry points. The validate() method on the request is perfect for controllers; a Form Request class (covered fully in the next lesson) moves that logic into a dedicated class.
In the controller
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string|min:10',
'published_at' => 'nullable|date',
]);
// On failure Laravel automatically redirects back with errors + old input.
// On success, $validated holds ONLY the fields above — safe to mass-assign.
$post = Post::create($validated);
return redirect()->route('posts.show', $post);
}
If validation fails on a normal browser request, Laravel throws a ValidationException, redirects back, and flashes the errors — you write no error-handling code at all. For a JSON/API request, it instead returns a 422 Unprocessable Entity response with the errors as JSON.
In a Form Request
php artisan make:request StorePostRequest
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // authorization logic goes here
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'body' => 'required|string|min:10',
];
}
}
// The controller becomes trivial — type-hint the request:
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
The Rule Catalog
Laravel ships with dozens of rules. You combine them with a pipe (|) or as an array. Here are the ones you'll reach for constantly, grouped by what they check:
| Category | Common rules |
|---|---|
| Presence | required, nullable, filled, present, required_if:field,value, required_with:a,b, required_without:a |
| Strings | string, alpha, alpha_dash, alpha_num, email, url, min:n, max:n, size:n, regex:/.../, starts_with, ends_with |
| Numbers | numeric, integer, decimal:2, digits:n, min:n, max:n, between:a,b, gt:field, lt:field |
| Dates | date, date_format:Y-m-d, after:today, after_or_equal:date, before:date |
| Choice | in:a,b,c, not_in:x,y, boolean, array, distinct, json |
| Files | file, image, mimes:pdf,docx, mimetypes:..., dimensions:..., max:kb |
| Database | exists:table,column, unique:table,column |
💡 File sizes are in kilobytes
max:2048 on an image means 2048 KB — i.e. 2 MB. Multiply the MB you want by 1024.
A realistic combination looks like this:
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'age' => 'nullable|integer|between:13,120',
'role' => 'required|in:admin,editor,user',
'starts_at' => 'required|date|after:today',
'avatar' => 'nullable|image|max:2048',
]);
Arrays & the Wildcard
When a form submits a list — tags, line items, multiple images — the * wildcard validates each element. Rules on the array itself (like min/max count) go on the bare key; rules on each item go on the key.* path.
$request->validate([
// The array itself: required, 1 to 5 entries
'tags' => 'required|array|min:1|max:5',
// Each entry must be a short string
'tags.*' => 'string|max:50',
]);
The notation nests as deeply as your data. Validating an array of user objects:
$request->validate([
'users' => 'required|array|min:1',
'users.*.name' => 'required|string|max:255',
'users.*.email' => 'required|email|distinct', // distinct = no duplicates in the array
'users.*.roles' => 'array',
'users.*.roles.*' => 'exists:roles,id',
]);
The distinct rule is a gem here: it rejects the submission if two rows share the same email, without a database round-trip.
Password & Rule Builders
Some constraints are awkward to express as pipe strings. Laravel provides fluent builder objects you drop into the array form of a rule set.
The Password builder
use Illuminate\Validation\Rules\Password;
$request->validate([
'password' => [
'required',
'confirmed', // must match the password_confirmation field
Password::min(8)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // checks the Have I Been Pwned breach database
],
]);
uncompromised() is a standout: it hashes the password and asks the Have I Been Pwned API (using k-anonymity, so the password never leaves your server intact) whether it has appeared in a known breach.
The Rule builder
Use Rule::unique() and Rule::exists() when you need constraints a plain string can't express — like ignoring the current record on an update, or scoping to a condition:
use Illuminate\Validation\Rule;
// On update: email must be unique EXCEPT for the current user's own row
$request->validate([
'email' => [
'required', 'email',
Rule::unique('users')->ignore($user->id),
],
// Category must exist AND be active
'category_id' => [
'required',
Rule::exists('categories', 'id')->where(fn ($query) => $query->where('active', true)),
],
]);
⚠️ The unique-on-update trap
Without ->ignore($user->id), updating a user and saving their own unchanged email fails the unique check — because their row already holds that email. ignore() exempts the current record.
Conditional & Dynamic Rules
Real forms have fields that only matter under certain conditions. Laravel gives you several tools, from declarative rules to fully dynamic logic.
Declarative conditionals
$request->validate([
'payment_type' => 'required|in:credit,paypal,bank',
// Only required when payment_type is credit; excluded otherwise
'card_number' => 'required_if:payment_type,credit|nullable|string',
'paypal_email' => 'required_if:payment_type,paypal|nullable|email',
// Validated only if it is present in the payload at all
'middle_name' => 'sometimes|string|max:255',
]);
Validator::sometimes() for complex conditions
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'games' => 'required|numeric',
]);
// Add a rule only when a closure returns true
$validator->sometimes('reason', 'required|max:500', function ($input) {
return $input->games >= 100;
});
$validated = $validator->validate();
Building the whole rule set dynamically
Inside a Form Request's rules() method you can branch on the HTTP method, route parameters, or input:
public function rules(): array
{
$rules = [
'name' => 'required|string|max:255',
'email' => 'required|email',
];
if ($this->isMethod('POST')) {
// Creating: password required and email globally unique
$rules['email'] = 'required|email|unique:users,email';
$rules['password'] = 'required|min:8|confirmed';
} else {
// Updating: ignore this user's own row; password optional
$userId = $this->route('user');
$rules['email'] = "required|email|unique:users,email,{$userId}";
$rules['password'] = 'nullable|min:8|confirmed';
}
return $rules;
}
Custom Validation Rules
When no built-in rule fits, write your own. Modern Laravel (9.3+, and the default in 11) uses the ValidationRule interface — a single validate() method that receives a $fail closure.
A reusable ValidationRule class
php artisan make:rule Uppercase
<?php
// app/Rules/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.');
}
if (! preg_match('/[A-Z]/', $value)) {
$fail('The :attribute must contain an uppercase letter.');
}
if (! preg_match('/[a-z]/', $value)) {
$fail('The :attribute must contain a lowercase letter.');
}
if (! preg_match('/[0-9]/', $value)) {
$fail('The :attribute must contain a number.');
}
if (! preg_match('/[^A-Za-z0-9]/', $value)) {
$fail('The :attribute must contain a special character.');
}
}
}
Call $fail() for each problem — you can report several messages at once. Use the rule by passing a fresh instance:
use App\Rules\StrongPassword;
$request->validate([
'password' => ['required', new StrongPassword],
]);
📖 Legacy note: the old Rule interface
Older tutorials show implements Rule with separate passes() and message() methods, plus an InvokableRule variant. Both are deprecated. The single-method ValidationRule interface shown above is the current standard — prefer it in new code.
Inline closures for one-offs
For a check you'll only use once, skip the class and inline a closure:
$request->validate([
'title' => [
'required',
function (string $attribute, mixed $value, Closure $fail) {
if (str_contains(strtolower($value), 'admin')) {
$fail("The {$attribute} may not contain the word 'admin'.");
}
},
],
]);
Customizing Messages
Laravel's default messages are decent, but you'll often want friendlier wording. There are three levels, from most local to most global.
Per-call, in the controller
$request->validate([
'title' => 'required|max:255',
'body' => 'required',
], [
'title.required' => 'A title is required.',
'title.max' => 'Keep the title under :max characters.',
'body.required' => 'Please write something.',
]);
Messages support placeholders: :attribute (the field name), rule parameters like :min and :max, and :values for list rules such as in.
In a Form Request
public function messages(): array
{
return [
'title.required' => 'A title is required.',
'email.unique' => 'That email is already registered.',
];
}
// Rename a field in messages (":attribute" becomes "email address")
public function attributes(): array
{
return ['email' => 'email address'];
}
Globally, in the language file
For app-wide wording and translations, edit lang/en/validation.php (publish it first with php artisan lang:publish on Laravel 11):
// lang/en/validation.php
return [
'required' => 'The :attribute field is required.',
// Field-and-rule-specific overrides
'custom' => [
'email' => [
'required' => 'We really need your email address.',
],
],
// Friendly names used in every message
'attributes' => [
'email' => 'email address',
],
];
Hands-on Exercise
🏋️ A Phone Number Rule + Conditional Signup
Objective: Combine a custom rule with conditional validation on a realistic form.
Instructions:
- Generate a rule:
php artisan make:rule PhoneNumber. - In its
validate()method, strip formatting characters and fail unless the remaining string is 10–15 digits. - Write a
StoreAccountRequestForm Request with anaccount_typefield (in:individual,business). - Require
company_nameandtax_idonly whenaccount_typeisbusiness. - Apply your
PhoneNumberrule to a requiredphonefield, and add a custom message for it.
💡 Hint
Use required_if:account_type,business for the business-only fields. In the rule, normalize first: $digits = preg_replace('/\D/', '', $value); then check strlen($digits) is between 10 and 15.
✅ Solution outline
// app/Rules/PhoneNumber.php
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$digits = preg_replace('/\D/', '', (string) $value);
if (strlen($digits) < 10 || strlen($digits) > 15) {
$fail('The :attribute must be a valid phone number (10–15 digits).');
}
}
// app/Http/Requests/StoreAccountRequest.php
public function rules(): array
{
return [
'account_type' => 'required|in:individual,business',
'company_name' => 'required_if:account_type,business|nullable|string|max:255',
'tax_id' => 'required_if:account_type,business|nullable|string|max:50',
'phone' => ['required', new \App\Rules\PhoneNumber],
];
}
public function messages(): array
{
return ['phone.required' => 'A contact phone number is required.'];
}
Summary & Quiz
🎉 Key Takeaways
- Always validate server-side — client checks are convenience, not defense.
- The rule catalog covers presence, strings, numbers, dates, choice, files, and the database.
- The
*wildcard validates each element of an array, at any depth. - The
PasswordandRulebuilders express constraints that pipe strings can't — includingunique()->ignore()for updates. - Add rules conditionally with
required_if,sometimes, and dynamicrules()logic. - Write custom rules as
ValidationRuleclasses (the modern interface) or inline closures.
🎯 Quick Quiz
Question 1: A form submits tags as an array and you want each tag to be a string no longer than 50 characters. Which rule key targets each element?
Question 2: On an update form, saving a user's own unchanged email fails the unique:users,email rule. What's the fix?
Question 3: Which is the current, recommended way to write a reusable custom rule in Laravel 11?
📚 Further Reading
🚀 What's Next?
You've seen validation logic living in controllers and briefly in Form Requests. Next we dedicate a whole lesson to Form Request classes — moving validation and authorization into reusable, testable classes with a rich lifecycle of hooks.
🎉 Nicely done!
Your data is now guarded by exactly the rules you choose. Let's give that guarding a proper home.