Backend Development

Laravel Validation: Custom Rules and Messages

Mayur Dabhi
Mayur Dabhi
April 6, 2026
13 min read

A signup form starts with three fields and a one-line validate() call. Six months later the controller has forty lines of rules, the same password check is copy-pasted into three places, and someone asks why the error says "The dob field is required" instead of something a human would write. That's usually the point where you need more than the basics.

This post goes from inline validation to custom error messages, Form Requests, Rule objects, closures, and conditional rules, plus a few gotchas that bite people in production.

Why Laravel Validation?

Laravel ships with 90+ built-in rules, redirects back with errors automatically when validation fails, exposes those errors to Blade through $errors, and gives you a small API for writing your own rules. For most apps you won't need a third-party validation 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 redirects the user back with the errors flashed to the session. You don't write any of that yourself.

PHP: Controller inline validation
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.');
}

These are the built-in rules I reach for most often:

RulePurposeExample
requiredField must be present and non-empty'name' => 'required'
stringMust be a string'bio' => 'string'
integerMust be an integer'age' => 'integer'
numericMust be numeric (int or float)'price' => 'numeric'
emailMust be a valid email address'email' => 'email'
urlMust be a valid URL'website' => 'url'
min:n / max:nLength or value bounds'password' => 'min:8'
unique:table,colMust not exist in database'email' => 'unique:users'
exists:table,colMust exist in database'category_id' => 'exists:categories,id'
in:a,b,cMust be one of the listed values'status' => 'in:active,inactive'
confirmedMust have matching _confirmation field'password' => 'confirmed'
imageMust be an image file'avatar' => 'image'
arrayMust be an array'tags' => 'array'
dateMust be a valid date'dob' => 'date'
regex:patternMust match a regex'phone' => 'regex:/^[0-9]{10}$/'

Custom Error Messages

The default messages are fine for a prototype, but they read like they were written by a framework. Users respond better to "That email is already registered. Try logging in instead." than to "The email has already been taken."

Inline Message Overrides

Pass a second array to validate() with keys in the format field.rule:

PHP: Custom messages in controller
$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:

PHP: Custom attribute names
$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:

lang/en/validation.php
// 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

Once a form has more than a handful of fields, inline rules start crowding out the actual controller logic. Form Requests move validation (and authorization) into their own class, so the controller only deals with valid data and you can reuse or test the rules separately.

1

Generate the Form Request

php artisan make:request StoreUserRequest creates app/Http/Requests/StoreUserRequest.php.

2

Define the authorize() method

Return true to allow all users, or add policy/role checks here. Returning false gives a 403.

3

Define the rules() method

Return your validation rules array, in the same format as inline validation.

4

Type-hint it in the controller

Replace Request $request with StoreUserRequest $request. Validation runs automatically before your controller method is called.

app/Http/Requests/StoreUserRequest.php
<?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',
        ];
    }
}
app/Http/Controllers/UserController.php
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 built-in rules fit, write a Rule object. I use these for anything that shows up in more than one form: a phone number format, a VAT number check, or a company-specific password policy.

Terminal
php artisan make:rule StrongPassword
app/Rules/StrongPassword.php
<?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.');
        }
    }
}

Then drop an instance into the rules array next to the built-in rules:

PHP: Using a custom Rule object
use App\Rules\StrongPassword;

$request->validate([
    'password' => ['required', 'confirmed', new StrongPassword()],
]);
Rule objects vs closures

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.

Gotcha: custom rules skip empty fields

A custom rule doesn't run at all when the field is missing or an empty string. So ['password' => [new StrongPassword()]] without required happily accepts a blank password. Either add required (usually what you want) or implement Illuminate\Contracts\Validation\ImplicitRule if the rule itself needs to judge empty values. Also note that for JSON requests (an Accept: application/json header), a failed validation returns a 422 with an errors object instead of a redirect. If your SPA gets a 302 to the home page instead, check that header first.

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:

PHP: Closure validation rule
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

Checkout forms are the classic case: card fields only matter if the user picked "card". For that you've got required_if, required_with, required_unless, and the sometimes() method on a Validator instance.

PHP: Conditional rules
$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():

PHP: Validator::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();
HTTP Request POST /users Form Request authorize() + rules() Redirect + Errors 422 / back() FAIL PASS Controller $request->validated() Response 200 OK / redirect

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:

PHP: stopOnFirstFailure
$validator = Validator::make($request->all(), $rules);
$validator->stopOnFirstFailure()->validate();

Array Validation

Validating each element of a submitted array uses the * wildcard:

PHP: Array field validation
$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

after() runs your own logic once the regular rules have been evaluated. Note that it runs even if some of them failed, so guard against missing values. I use it for cross-field checks like date ranges:

PHP: after() hook
$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();

Mistakes I see most often

  • Passing $request->all() to create(). Anything the user adds to the payload goes along for the ride. Use $request->validated() so only fields with rules get through.
  • Forgetting the ignore on update. unique:users,email fails when a user saves their profile without changing their email. On update forms use Rule::unique('users')->ignore($user->id).
  • Making a Form Request for every two-field form. For a search box or a single toggle, inline validate() is easier to read. I only reach for a Form Request when there's authorization logic or the rules are reused.
  • Writing a Rule object that duplicates a built-in. The StrongPassword class above is a good learning example, but in a real app Password::min(8)->mixedCase()->numbers()->symbols() already does it, and ->uncompromised() adds a breach check you'd never write yourself.
  • Doing database lookups in after() without thinking. An after() hook that queries per array item can turn a 50-row import into 50 queries. Load what you need once, outside the loop.
Laravel Validation PHP Forms Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.