Three Ways to Validate

ApproachBest for
$request->validate()Two or three fields, one place
Form Request classAnything real — reusable, testable, keeps controllers thin
Validator::make()Data that did not come from an HTTP request

All three share the same rule syntax and the same underlying validator. Picking one is about where the rules live, not what they can express.

Validating in the Controller

public function store(Request $request)
{
    $validated = $request->validate([
        'title'   => ['required', 'string', 'max:255'],
        'slug'    => ['required', 'alpha_dash', 'unique:posts,slug'],
        'body'    => ['required', 'string', 'min:50'],
        'status'  => ['required', 'in:draft,published'],
        'tags'    => ['nullable', 'array', 'max:5'],
        'tags.*'  => ['integer', 'exists:tags,id'],
    ]);

    $post = Post::create($validated);

    return redirect()->route('posts.show', $post);
}

On failure Laravel throws a ValidationException. For a normal web request it redirects back with the errors and the old input; for a request that expects JSON it returns a 422 with an errors object. You write no error-handling code either way.

Use the array syntax (['required', 'max:255']) rather than the pipe string ('required|max:255'). Array syntax survives rules that contain a pipe character — regex rules in particular — and lets you mix in rule objects.

For non-request data, build the validator yourself:

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($csvRow, [
    'email' => ['required', 'email'],
    'age'   => ['required', 'integer', 'min:18'],
]);

if ($validator->fails()) {
    Log::warning('Bad row', $validator->errors()->toArray());
    continue;
}

$clean = $validator->validated();

Form Requests — The Default Choice

php artisan make:request StorePostRequest
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    public function rules(): array
    {
        return [
            'title'  => ['required', 'string', 'max:255'],
            'slug'   => ['required', 'alpha_dash', Rule::unique('posts')],
            'body'   => ['required', 'string', 'min:50'],
            'status' => ['required', Rule::in(['draft', 'published'])],
        ];
    }

    // Normalise input BEFORE the rules run
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->slug ?: $this->title),
        ]);
    }

    // Extra checks that need the whole payload
    public function withValidator($validator): void
    {
        $validator->after(function ($validator) {
            if ($this->status === 'published' && ! $this->published_at) {
                $validator->errors()->add('published_at', 'A publish date is required.');
            }
        });
    }
}

Type-hint it and the controller shrinks to the part that matters:

public function store(StorePostRequest $request)
{
    $post = Post::create($request->validated());

    return redirect()->route('posts.show', $post);
}

Laravel resolves the form request out of the container, runs authorize(), then rules(), and never enters your controller unless both pass. That gives you authorisation and validation in one place, unit-testable in isolation.

authorize() returning false produces a 403, not a validation error. If you scaffold a request and forget to change the default, every submission is forbidden — a classic five-minute debugging session.

Grab a subset when you only want some of the validated keys:

$request->validated();                       // everything that passed
$request->safe()->only(['title', 'body']);
$request->safe()->except(['slug']);
$request->safe()->merge(['user_id' => auth()->id()]);

The Rules You Will Actually Use

RuleWhat it checks
required / nullableMust be present and non-empty / may be null
string, integer, numeric, booleanType
min:8 / max:255Length for strings, value for numbers, count for arrays, KB for files
between:1,10 / size:5Range / exact
email:rfc,dnsValid address, optionally with a real MX record
unique:users,emailNo matching row exists
exists:tags,idA matching row does exist
confirmedRequires a matching {field}_confirmation
date, after:today, before_or_equal:end_dateDates and ordering
image, mimes:pdf,docx, max:2048Uploaded files
in:a,b,c / not_in:xAllow / deny list
url, ip, uuid, json, regex:/.../Format

Ignoring the current row on update is the classic unique gotcha:

// Update — the user's own email must not count as a duplicate
'email' => [
    'required', 'email',
    Rule::unique('users')->ignore($this->user->id),
],

// Scoped uniqueness — slug unique per tenant, ignoring soft-deleted rows
'slug' => [
    Rule::unique('posts')
        ->where(fn ($q) => $q->where('tenant_id', $this->tenant_id)
                             ->whereNull('deleted_at')),
],

Passwords have a dedicated fluent rule:

use Illuminate\Validation\Rules\Password;

'password' => [
    'required', 'confirmed',
    Password::min(10)->letters()->mixedCase()->numbers()->symbols()->uncompromised(),
],

uncompromised() checks the password against the Have I Been Pwned breach corpus using a k-anonymity hash prefix — the plaintext never leaves your server.

Custom Messages and Attribute Names

public function messages(): array
{
    return [
        'title.required' => 'Give your post a title.',
        'body.min'       => 'The post needs at least :min characters — you wrote :input.',
        'tags.*.exists'  => 'One of the selected tags no longer exists.',
    ];
}

public function attributes(): array
{
    return [
        'dob'         => 'date of birth',
        'addr_line_1' => 'street address',
    ];
}

Without attributes(), Laravel prints “The addr line 1 field is required.” With it you get “The street address field is required.” It is a two-line change that noticeably improves a form.

For site-wide wording, edit lang/en/validation.php instead of repeating messages on every request class.

Conditional Rules

'company_name' => ['required_if:account_type,business', 'string', 'max:120'],
'vat_number'   => ['required_with:company_name'],
'coupon'       => ['required_unless:total,0'],
'end_date'     => ['nullable', 'date', 'after:start_date'],

// sometimes — only validate the key if it is present in the payload
'nickname' => ['sometimes', 'string', 'max:30'],

// Rule::when — apply a set of rules only when a condition holds
'shipping_address' => Rule::when(
    fn () => $this->requires_shipping,
    ['required', 'string', 'max:255'],
    ['nullable']
),

// exclude_if — drop the field entirely from validated() when irrelevant
'card_number' => ['exclude_if:payment_method,invoice', 'required', 'digits:16'],

sometimes is what you want for PATCH endpoints. It validates a field only when the client actually sent it, so partial updates do not trip over required rules on untouched fields.

Arrays and Nested Data

Dot notation and * reach into nested payloads:

// Payload:
// { "customer": { "name": "..." },
//   "items": [ { "sku": "...", "qty": 2 }, ... ] }

return [
    'customer'        => ['required', 'array'],
    'customer.name'   => ['required', 'string', 'max:120'],
    'customer.email'  => ['required', 'email'],

    'items'           => ['required', 'array', 'min:1', 'max:50'],
    'items.*.sku'     => ['required', 'string', 'exists:products,sku'],
    'items.*.qty'     => ['required', 'integer', 'min:1', 'max:99'],
    'items.*.note'    => ['nullable', 'string', 'max:200'],
];

Reference the index in a message with :position, or use Rule::forEach() when each element needs rules derived from its own contents:

public function messages(): array
{
    return [
        'items.*.qty.min' => 'Line :position must have a quantity of at least 1.',
    ];
}

Adding 'items' => ['array'] is not optional. Without it a client can post items=hello and the items.* rules simply never run — the payload passes validation and your loop crashes downstream.

Writing Custom Rules

For a one-off check, a closure is enough:

'domain' => [
    'required',
    function (string $attribute, mixed $value, Closure $fail) {
        if (str_ends_with($value, '.test')) {
            $fail('The :attribute may not use a .test domain.');
        }
    },
],

For anything reused, generate a rule object:

php artisan make:rule ValidVatNumber
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidVatNumber implements ValidationRule
{
    public function __construct(private string $country) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! VatService::isValid($this->country, $value)) {
            $fail('The :attribute is not a valid VAT number for :country.')
                ->translate(['country' => $this->country]);
        }
    }
}

// Usage
'vat_number' => ['required', new ValidVatNumber($this->country)],

Rule objects are plain classes, so you can inject services, unit test them directly, and reuse them across every request that needs the same check.

Validation Errors in an API

Laravel already returns a sensible 422 when the request expects JSON:

{
  "message": "The title field is required. (and 1 more error)",
  "errors": {
    "title": ["The title field is required."],
    "items.0.qty": ["The items.0.qty must be at least 1."]
  }
}

Two things are worth adding. First, force JSON on API routes so a missing Accept header does not get a redirect:

class ForceJsonResponse
{
    public function handle(Request $request, Closure $next): Response
    {
        $request->headers->set('Accept', 'application/json');

        return $next($request);
    }
}

Second, override the response shape when your API contract requires something specific:

// In the FormRequest
protected function failedValidation(Validator $validator): void
{
    throw new HttpResponseException(
        response()->json([
            'status' => 'error',
            'code'   => 'VALIDATION_FAILED',
            'errors' => $validator->errors(),
        ], 422)
    );
}

Practical Tips

  • Validate at the boundary, enforce in the database. Validation rules are a good user experience; unique indexes and foreign keys are what actually guarantee integrity under concurrency.
  • Never pass raw $request->all() to create(). Use validated() — it returns only keys you wrote rules for, which is mass-assignment protection you get for free.
  • Separate Store and Update requests. They almost always diverge on unique and required. Share the common parts in a base class or trait.
  • Test the rules, not the controller. A form request is a plain class — instantiate it, feed rules() to Validator::make(), and assert on failures.
  • @error in Blade keeps templates tidy: @error('title')<span>{{ $message }}</span>@enderror.