Laravel 13 Validation: Quick Start Guide
Every web application needs to check incoming data before trusting it. Without validation, controllers fill up with manual checks, models accept garbage, and users get cryptic 500 errors instead of helpful messages.
Laravel gives you three ways to validate request data: the validate() function directly in a controller, a dedicated Form Request class, and manual validator creation via the Validator facade. Each approach fits a different scenario, and this tutorial walks through all of them with working examples.
Validating in a Controller
The fastest way to validate a request in a controller is calling $request->validate() right inside the method. This validates form data (or any request data) in one call and returns only the fields that passed:
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|unique:posts|max:255',
'body' => 'required|string',
]);
Post::create($validated);
return to_route('posts.index');
}
If the data fails validation, Laravel throws a ValidationException. For a regular HTTP request, this means a redirect back with errors flashed to the session. For an XHR or API request (when Accept: application/json is set), it returns a 422 JSON response.
The key point: execution stops at validate() if anything is wrong. Code after it only runs when all rules pass.
String vs Array Syntax
Rules can be written as a pipe-delimited string or as an array. Both produce the same result:
// String syntax – compact, works for simple rules
'email' => 'required|email|max:255',
// Array syntax – mandatory when a rule contains commas or uses Rule objects
'email' => ['required', 'email:rfc,dns', Rule::unique('users')->ignore($user->id)],
Array syntax is also required for regex patterns that contain |, since the pipe would otherwise be parsed as a rule separator.
Stopping on First Failure
By default, Laravel checks every rule for every field and collects all errors at once. Adding bail to a field stops its validation chain after the first failure:
$validated = $request->validate([
'email' => 'bail|required|email|unique:users',
'name' => 'required|string|max:100',
]);
This prevents unnecessary work – if email fails the required check, there is no reason to hit the database for unique. The name field is still validated independently.
More on bail and flow control in the validation rules reference.
Form Request – a Dedicated Validation Class
When a controller method accumulates ten rules, custom messages, and authorization logic, it is time to extract that into a Form Request:
php artisan make:request StoreArticleRequest
The generated class goes to app/Http/Requests:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreArticleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Article::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:50'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:5'],
'tags.*' => ['string', 'max:30'],
];
}
}
Type-hint it in the controller, and Laravel runs validation before the method body executes:
public function store(StoreArticleRequest $request): RedirectResponse
{
$article = Article::create($request->validated());
return to_route('articles.show', $article);
}
The authorize() method controls access. If it returns false, the user gets a 403 without rules ever running. For public forms, just return true. If authorization lives in middleware or gates, you can remove the method entirely.
Naming convention: action + entity + Request. StoreArticleRequest, UpdateUserRequest, DestroyCommentRequest. The name tells you exactly which endpoint it serves.
Full coverage of Form Requests – prepareForValidation(), after() hooks, attributes, redirect customization – in the Form Request article.
Validator::make() – Manual Creation
The third approach builds a validator manually via the Validator facade. Use it when data comes from somewhere other than an HTTP request – a queue job, an Artisan command, a CSV import:
use Illuminate\Support\Facades\Validator;
$validator = Validator::make($row, [
'email' => ['required', 'email'],
'age' => ['required', 'integer', 'min:18'],
]);
if ($validator->fails()) {
Log::warning('Row skipped', ['errors' => $validator->errors()->all()]);
continue;
}
$clean = $validator->validated();
Calling $validator->validate() (without the if) works like $request->validate() – it throws a ValidationException on failure. In web context that triggers a redirect; in background jobs you catch it yourself.
Choosing the Right Approach
Each approach exists for a reason.
For controllers with 2–5 rules, $request->validate() is enough. A contact form, a search filter, a settings toggle. Creating a whole class for that is unnecessary overhead.
Form Request earns its keep when logic piles up: custom messages, prepareForValidation(), after() hooks, a dozen rules. It is also easier to test in isolation – you can unit-test rules() without booting the HTTP stack.
Validator::make() is for everything outside HTTP. Queues processing CSV rows, Artisan commands checking config, services handling webhooks – none of these have a $request object.
In practice, most projects use Form Requests for 80% of endpoints, $request->validate() for simple cases, and Validator::make() for background tasks.
The best way to validate depends on where the data comes from and how complex the rules are. A quick decision guide:
- Data from HTTP request, few rules →
$request->validate()in the controller - Data from HTTP request, many rules or custom logic → Form Request class
- Data from queue, command, service, or external API →
Validator::make() - Need real-time validation on the frontend → add Precognition middleware to the route
All three approaches use the same validation rules and produce the same error structures. Switching between them later is straightforward – the rules array stays the same, only the container changes.
Working with Validated Data
After validation, do not pass $request->all() to your models. Use the validated output instead – this prevents mass assignment attacks where a user adds fields like is_admin=1 to the request:
// Full validated array
$data = $request->validated();
// Pick specific fields
$only = $request->safe()->only(['name', 'email']);
// Exclude fields
$without = $request->safe()->except(['password_confirmation']);
// Add fields after validation
$merged = $request->safe()->merge(['ip' => $request->ip()]);
The safe() method returns a ValidatedInput instance – a validated form object that holds only the fields that passed rules. You can iterate over it, access it like an array, or convert it to a collection.
Displaying Errors in Blade
When validation fails on a web request, Laravel redirects back and stores errors in the session. The $errors variable is available in every view through the ShareErrorsFromSession middleware:
@if ($errors->any())
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
For field-level errors, the @error directive is more convenient:
<label for="email">Email</label>
<input
type="email"
name="email"
value="{{ old('email') }}"
class="@error('email') border-red-500 @enderror"
>
@error('email')
<p class="text-red-500">{{ $message }}</p>
@enderror
The old() helper restores the previous input from the session, so users do not lose what they typed when a validation error occurs.
Customizing error messages, localization, and JSON error format are covered in the error messages article.
JSON Responses for APIs
When the incoming request expects JSON (Accept: application/json header or XHR), Laravel skips the redirect and returns a 422 response:
{
"message": "The title field is required. (and 1 more error)",
"errors": {
"title": [
"The title field is required."
],
"body": [
"The body field is required."
]
}
}
No extra configuration needed. Both $request->validate() and Form Requests detect the expected format automatically. Frontend frameworks like Vue, React, or Inertia receive errors in a predictable structure where keys match field names.
Optional Fields and nullable
Laravel includes the TrimStrings and ConvertEmptyStringsToNull middleware in its global middleware stack. TrimStrings strips whitespace, ConvertEmptyStringsToNull converts "" to null. This means an empty optional <input> arrives as null, and without nullable it will fail any type rule:
$request->validate([
'title' => ['required', 'string', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'publish_at' => ['nullable', 'date', 'after:today'],
]);
Without nullable, an empty subtitle becomes null and fails the string check. Rule of thumb: every optional field needs nullable. Date-specific rules like after, before, and date_format are detailed in the dates article. For file uploads, see the files article.
The distinction between nullable, sometimes, filled, and present is covered in the validation rules reference.
Preparing Data Before Validation
The prepareForValidation() method in Form Request transforms input before rules run:
protected function prepareForValidation(): void
{
$this->merge([
'slug' => Str::slug($this->title),
'phone' => preg_replace('/[^\d+]/', '', $this->phone),
]);
}
Common uses: normalizing phone numbers, generating slugs, stripping HTML. Rules receive clean data.
The reverse hook – passedValidation() – runs after successful validation and lets you modify data before the controller sees it.
Additional Validation with after()
The after() method in Form Request adds checks that run after the standard rules pass. This is the place for business logic that cannot be expressed as a rule:
public function after(): array
{
return [
function (\Illuminate\Validation\Validator $validator) {
if ($this->overlapsExistingBooking()) {
$validator->errors()->add(
'date',
'The selected date overlaps with an existing booking.'
);
}
},
];
}
For complex checks, extract them into invokable classes (or build a full custom Rule):
public function after(): array
{
return [
new ValidateBookingAvailability,
new ValidatePaymentMethod,
];
}
Custom Error Messages
Default Laravel messages are functional but generic. Override them in Form Request through the messages() method:
public function messages(): array
{
return [
'title.required' => 'Every article needs a title.',
'title.max' => 'The title cannot exceed :max characters.',
'email.unique' => 'This email address is already registered.',
];
}
With Validator::make(), pass messages as the third argument:
$validator = Validator::make($data, $rules, [
'required' => 'The :attribute field cannot be left blank.',
]);
The :attribute placeholder is replaced with the field name. To show “email address” instead of “email”, override attributes():
public function attributes(): array
{
return [
'email' => 'email address',
'dob' => 'date of birth',
'company_id' => 'company',
];
}
Full guide on localization, language files, and custom placeholders in the error messages article.
Multiple Forms on One Page
Two independent forms on the same page (login and signup, filter and search, two admin modals) share a single error bag by default, so a failed submission in one form lights up @error blocks in the other. Named bags isolate them: $request->validateWithBag('register', [...]) for FormRequest flows, redirect()->withErrors($validator, 'login') or ->validateWithBag('login') for manual validators. Errors are then read by bag name in Blade: $errors->register->first('email') or @error('email', 'register'). A worked example with two forms in one view, the withErrors semantics, and Validator::make interaction live in the error messages article.
Conditional Rules
Rules can depend on other fields in the request. The simplest way is required_if, required_with, and required_without:
$request->validate([
'account_type' => ['required', 'in:personal,business'],
'company_name' => ['required_if:account_type,business', 'string', 'max:200'],
'tax_id' => ['required_with:company_name', 'string', 'size:12'],
]);
For more complex conditions, the manual validator supports a sometimes() method that accepts a closure:
use Illuminate\Support\Fluent;
$validator->sometimes('company_name', 'required|string|max:200', function (Fluent $input) {
return 'business' === $input->account_type;
});
The full set of conditional rules is covered in the conditional validation article.
Nested Attributes and Dot Notation
For nested data, use dots in field names:
$request->validate([
'address.city' => ['required', 'string'],
'address.zip' => ['required', 'string', 'size:5'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);
The wildcard * applies validation rules to every element of an array. If the array is empty and optional, none of the wildcard rules fire – that is expected behavior. For complex per-item logic, Rule::forEach gives you access to each item’s value and index.
If a field name contains a literal dot, escape it with a backslash:
$request->validate([
'v2\.0' => ['required', 'string'],
]);
More on array validation, wildcards, and the distinct rule in the arrays and JSON article.
stopOnFirstFailure
By default, bail stops one field’s chain on first error, but other fields are still validated. To stop everything on the first failure across all fields:
if ($validator->stopOnFirstFailure()->fails()) {
// $validator->errors() has only the first error
}
In a Form Request, use the attribute:
use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;
#[StopOnFirstFailure]
class ImportRowRequest extends FormRequest
{
// ...
}
This is mainly useful for imports and batch operations where failing fast saves processing time.
Validation Outside HTTP
Validator::make() works anywhere – not just in controllers. In an Artisan command:
class ImportUsersCommand extends Command
{
protected $signature = 'users:import {file}';
public function handle(): int
{
$rows = CsvReader::read($this->argument('file'));
$skipped = 0;
foreach ($rows as $i => $row) {
$validator = Validator::make($row, [
'email' => ['required', 'email', 'unique:users'],
'name' => ['required', 'string', 'max:100'],
]);
if ($validator->fails()) {
$this->warn("Row {$i}: " . $validator->errors()->first());
$skipped++;
continue;
}
User::create($validator->validated());
}
$this->info("Done. Skipped: {$skipped}");
return self::SUCCESS;
}
}
The same pattern works in queue jobs, seeders, and service classes. The key difference from controller validation: in background tasks there is no redirect, so you handle errors explicitly through fails() and errors().
Validation in a Service Layer
When data comes from a webhook or an internal service rather than an HTTP request, Validator::make() fits naturally:
class PaymentWebhookService
{
public function process(array $payload): void
{
$validated = Validator::make($payload, [
'event' => ['required', 'in:payment.success,payment.failed'],
'order_id' => ['required', 'exists:orders,id'],
'amount' => ['required', 'numeric', 'min:0.01'],
])->validate();
$order = Order::findOrFail($validated['order_id']);
if ('payment.success' === $validated['event']) {
$order->markAsPaid($validated['amount']);
}
}
}
Calling ->validate() on the validator instance throws ValidationException on failure – the controller handling the webhook catches it and returns 422.
Validation on Update
Updating a record differs from creating one in exactly one place: the unique rule must ignore the current record. Without this, a user cannot save their own profile because their email is “already taken”:
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => [
'required',
'email',
Rule::unique('users')->ignore($this->user()),
],
];
}
}
More on unique, exists, soft deletes, and multi-column constraints in the unique and exists article.
Full Example: Registration Form
Routes:
Route::get('/register', [RegisterController::class, 'create']);
Route::post('/register', [RegisterController::class, 'store']);
Form Request:
class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:60'],
'email' => ['required', 'email:rfc,dns', 'unique:users'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
];
}
}
The email:rfc,dns mode checks both RFC compliance and DNS records – other modes are described in the strings and numbers article. The Password builder enforces complexity requirements; see the passwords article for details.
Controller:
class RegisterController extends Controller
{
public function create(): View
{
return view('auth.register');
}
public function store(RegisterRequest $request): RedirectResponse
{
$user = User::create($request->validated());
Auth::login($user);
return to_route('dashboard');
}
}
Blade form (fragment):
<form method="POST" action="/register">
@csrf
<input type="text" name="name" value="{{ old('name') }}">
@error('name') <span>{{ $message }}</span> @enderror
<input type="email" name="email" value="{{ old('email') }}">
@error('email') <span>{{ $message }}</span> @enderror
<input type="password" name="password">
@error('password') <span>{{ $message }}</span> @enderror
<input type="password" name="password_confirmation">
<button type="submit">Register</button>
</form>
Customizing the Redirect on Failure
By default, failed validation redirects back to the previous page. In Form Request you can override this with an attribute:
use Illuminate\Foundation\Http\Attributes\RedirectTo;
#[RedirectTo('/dashboard')]
class UpdateProfileRequest extends FormRequest
{
// ...
}
Or redirect to a named route:
use Illuminate\Foundation\Http\Attributes\RedirectToRoute;
#[RedirectToRoute('profile.edit')]
class UpdateProfileRequest extends FormRequest
{
// ...
}
With a manual validator, you control the redirect yourself:
if ($validator->fails()) {
return redirect('/settings')
->withErrors($validator)
->withInput();
}
The withInput() call saves the submitted values to the session so old() can restore them in the form.
Live Validation with Precognition
Laravel Precognition enables live validation – fields are checked as the user fills them out, before the form is submitted. This is useful for ajax-heavy frontends where waiting until submit to show errors feels sluggish:
Route::post('/articles', [ArticleController::class, 'store'])
->middleware('precognitive');
On the frontend (Vue example):
import { useForm } from 'laravel-precognition-vue';
const form = useForm('post', '/articles', {
title: '',
body: '',
});
// Trigger validation on blur
function onBlur(field) {
form.validate(field);
}
Precognition sends a request with the Precognition: true header. Laravel executes middleware and resolves controller dependencies (including Form Request validation), but skips the controller method body itself. Rules are defined once in the Form Request and shared between real-time checks and final submit. No need to duplicate validation logic in JavaScript.
Adapters exist for Vue, React, and Alpine. Inertia ships with built-in Precognition support. This is the cleanest way to add live validation without duplicating any logic on the client side.
Testing Validation
Test both the happy path and the rejection:
public function test_registration_requires_name(): void
{
$response = $this->post('/register', [
'email' => '[email protected]',
'password' => 'SecurePass1',
'password_confirmation' => 'SecurePass1',
]);
$response->assertSessionHasErrors('name');
}
public function test_api_returns_422_on_invalid_input(): void
{
$response = $this->postJson('/api/orders', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['product_id', 'quantity']);
}
public function test_valid_data_creates_record(): void
{
$response = $this->post('/register', [
'name' => 'Jane Doe',
'email' => '[email protected]',
'password' => 'SecurePass1',
'password_confirmation' => 'SecurePass1',
]);
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
}
assertSessionHasErrors for web forms, assertJsonValidationErrors for APIs.
Common Mistakes
Using $request->all() instead of validated() – passes unvalidated fields to the model, opening the door to mass assignment exploits. Always use $request->validated() or $request->safe().
Forgetting nullable – the most common beginner issue on StackOverflow. Empty inputs become null after middleware, and without nullable they fail type rules like string or date.
authorize() returns false by default – a freshly generated Form Request has authorize() returning false. Every request gets a 403 with no indication of why. Change it to true or implement real authorization logic.
Ignoring the return value of validate() – calling $request->validate() but then reading $request->input(). The validated output is the safe data; raw input is not:
// Wrong – bypasses validation output
$request->validate(['title' => 'required']);
$title = $request->input('title');
// Right – uses validated data
$validated = $request->validate(['title' => 'required']);
$title = $validated['title'];
Copying rules between store and update – the rules are almost identical, but unique on update must ignore the current record. Copy-pasting the whole array and changing one line leads to drift over time. Extract shared rules into a trait or a base method, and let StoreRequest and UpdateRequest add their differences.
Wrapping validate() in try/catch – usually unnecessary. Laravel catches ValidationException automatically and produces a redirect or 422 response. Manual catching is only justified when you need a non-standard error format or want to log validation failures to monitoring.
Validation “not working” – common causes: if controller validation errors are not showing in Blade, check that the route uses the web middleware group (the ShareErrorsFromSession middleware lives there). For API routes, make sure the request sends Accept: application/json – otherwise Laravel returns a redirect instead of 422 JSON. Another frequent cause: $errors is empty because the page was opened via GET after a redirect, and the session has already been consumed. Also check that your form action URL matches the route – a mismatch means a different controller runs and the validation rules you expect never fire.
What’s Next
- Validation Rules – full reference of built-in rules
- Strings and Numbers – string, integer, email, URL, UUID, regex
- Dates and Time – date, date_format, after, before, timezone
- Arrays and JSON – array validation, nested data, JSON
- Files and Images – file, image, mimes, max size
- Unique and Exists – database validation, ignore on update
- Form Requests – dedicated classes, authorize, prepareForValidation
- Error Messages – customization, localization, JSON format
- Custom Rules – Rule classes, closures, implicit rules
- Passwords – Password::min(), confirmed, security
- Conditional Validation – required_if, when, exclude