Skip to main content

🛡️ 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 dynamic rules() logic
  • Use the fluent Password and Rule builders for complex constraints
  • Write custom rules as ValidationRule classes 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.

The validation gate User input passes through a validation layer; valid data proceeds to the application and database, while invalid data is sent back with errors. User Input Validation rules run here Valid → App & Database Invalid → back with errors
Figure 1 — Validation is a gate with two exits. Valid data flows on to your logic; invalid data returns to the user with actionable errors.

⚠️ 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:

CategoryCommon 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:

  1. Generate a rule: php artisan make:rule PhoneNumber.
  2. In its validate() method, strip formatting characters and fail unless the remaining string is 10–15 digits.
  3. Write a StoreAccountRequest Form Request with an account_type field (in:individual,business).
  4. Require company_name and tax_id only when account_type is business.
  5. Apply your PhoneNumber rule to a required phone field, 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 Password and Rule builders express constraints that pipe strings can't — including unique()->ignore() for updates.
  • Add rules conditionally with required_if, sometimes, and dynamic rules() logic.
  • Write custom rules as ValidationRule classes (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.