Three Ways to Validate
| Approach | Best for |
|---|---|
$request->validate() | Two or three fields, one place |
| Form Request class | Anything 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
| Rule | What it checks |
|---|---|
required / nullable | Must be present and non-empty / may be null |
string, integer, numeric, boolean | Type |
min:8 / max:255 | Length for strings, value for numbers, count for arrays, KB for files |
between:1,10 / size:5 | Range / exact |
email:rfc,dns | Valid address, optionally with a real MX record |
unique:users,email | No matching row exists |
exists:tags,id | A matching row does exist |
confirmed | Requires a matching {field}_confirmation |
date, after:today, before_or_equal:end_date | Dates and ordering |
image, mimes:pdf,docx, max:2048 | Uploaded files |
in:a,b,c / not_in:x | Allow / 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()tocreate(). Usevalidated()— 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
uniqueandrequired. 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()toValidator::make(), and assert on failures. @errorin Blade keeps templates tidy:@error('title')<span>{{ $message }}</span>@enderror.