Laravel 13 Validation Error Messages
Validation rules decide what passes and what fails. Error messages decide whether your users fix the problem or abandon the form. Default messages work fine during development, but production forms need human-readable field names, translated strings, and context-aware wording that matches the brand voice.
Below: how Laravel validation errors travel from the validator to Blade or a JSON client, how to customize error messages at every level, how to work with the MessageBag API, and how to take manual control when automatic behavior falls short. For the validation mechanics themselves – validate(), Form Requests, the Validator facade – see the quick start guide.
How the Error Flow Works
When validation fails, Laravel picks one of two paths depending on the request type.
Web requests: Laravel throws a ValidationException, which the exception handler converts into a redirect back to the previous URL. The error messages are flashed to the session, and old input is flashed alongside them so form fields can repopulate.
XHR / API requests: Laravel checks expectsJson() on the incoming request. This returns true when the request is an XMLHttpRequest that accepts any content type, or when the Accept header explicitly asks for JSON. If the check passes, Laravel skips the redirect and returns a JSON response with a 422 status code. The body looks like this:
{
"message": "The title field is required. (and 2 more errors)",
"errors": {
"title": [
"The title field is required."
],
"body": [
"The body must be at least 50 characters."
],
"category_id": [
"The selected category is invalid."
]
}
}
A common API mistake is parsing the top-level message field to extract errors. That string concatenates the first error with a count – it is meant for quick display, not for programmatic use. Always read individual errors from the errors object, where each key is a field name and each value is an array of messages.
The status code is always 422 Unprocessable Entity for both validate() and Form Requests. If you need a different code – say 400 for legacy clients – override failedValidation in a Form Request or catch ValidationException in a middleware.
Showing Validation Errors in Blade
The $errors variable is available in every Blade view served through the web middleware group. It is an instance of Illuminate\Support\ViewErrorBag, which proxies calls to one or more MessageBag instances inside it. Even when no errors exist, the variable is present and responds to methods like any() and count(), so you never get a null reference.
This works because the ShareErrorsFromSession middleware (registered in the web group) reads errors from the session and shares them with all views. API routes do not pass through this middleware, so $errors will not be available in views rendered from API endpoints. Keep this in mind when showing validation messages from routes outside the web group.
Listing All Errors
The simplest pattern – a block at the top of the form:
@if ($errors->any())
<div class="bg-red-50 border border-red-200 rounded p-4 mb-6">
<p class="font-semibold text-red-800">Please fix the following:</p>
<ul class="mt-2 list-disc list-inside text-red-700">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Per-Field Errors with @error
The @error directive is a shorthand for checking whether a specific field has messages. Inside its block, $message holds the first error for that field:
<div>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
value="{{ old('email') }}"
class="@error('email') border-red-500 @enderror"
>
@error('email')
<p class="text-red-600 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
Notice the @error directive inside the class attribute – this is a standard pattern for conditional styling. The old() helper repopulates the field so users do not lose their input on redirect. Combining @error, old(), and conditional classes into a reusable Blade component keeps forms DRY.
One thing to remember: @error('field') outputs the first message only. If a field has multiple errors (say it failed both email and max:255), only the first one appears. For all messages on a single field, loop over $errors->get('email').
Inline Errors for Array Fields
Displaying errors for array inputs in Blade needs dot notation matching:
@foreach ($items as $i => $item)
<div>
<input name="items[{{ $i }}][name]" value="{{ old("items.{$i}.name") }}">
@error("items.{$i}.name")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
@endforeach
More array validation patterns in the arrays and JSON article.
Custom Error Messages
Default messages come from lang/en/validation.php. They are functional but generic: “The email field must be a valid email address.” Real-world forms benefit from messages tailored to context – a registration page might say “We need your email to send a confirmation link.”
Laravel gives you three places to define custom error messages, each at a different scope. Where you put them depends on whether the override applies to a single form, a single request class, or every form in the application.
Inline Messages
Pass custom messages as the second argument to $request->validate() or the third argument to Validator::make():
$validated = $request->validate([
'email' => 'required|email|unique:users',
'age' => 'required|integer|min:18',
], [
'email.required' => 'We need your email address.',
'email.unique' => 'This email is already registered. Try logging in?',
'age.min' => 'You must be at least 18 years old.',
]);
The key format is field.rule. Defining just a rule name without a field – like 'required' => 'This field cannot be blank' – overrides the message for every field that uses that rule in this particular validator call. That is rarely what you want. Stick to field.rule to avoid scope confusion where 'required' => '...' silently overwrites messages for unrelated fields.
Form Request messages() Method
When using a Form Request, override messages() to keep custom messages next to the rules they belong to:
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'shipping_address' => ['required', 'string', 'max:500'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string', 'exists:products,sku'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
public function messages(): array
{
return [
'shipping_address.required' => 'Please provide a shipping address.',
'items.required' => 'Your cart is empty.',
'items.*.sku.exists' => 'Product :input is not available.',
'items.*.quantity.max' => 'Maximum 100 units per line.',
];
}
}
Language File (Global Scope)
For application-wide defaults, edit the language file. Fresh Laravel installations do not ship with a lang directory – you need to publish it first:
php artisan lang:publish
This creates lang/en/validation.php. Inside it, the custom array lets you target specific fields across the entire application:
// lang/en/validation.php
'custom' => [
'email' => [
'required' => 'We need your email address.',
'unique' => 'An account with this email already exists.',
],
'password' => [
'min' => 'Password must be at least :min characters.',
],
],
This is the right place for any custom message that repeats across forms – a login page and a registration page both validate email, and both should say the same thing.
Priority order when the same field and rule match multiple sources: inline messages (whether the 2nd arg to validate() or the return value of messages() in a Form Request – both end up in the same place internally) win over field-specific entries in lang/en/validation.php’s custom array, which win over the generic rule-level strings in that file.
Placeholders in Validation Messages
Laravel replaces several placeholder tokens inside message strings. A single template with variables can replace dozens of per-field messages.
Standard Placeholders
| Placeholder | Replaced with | Example output |
|---|---|---|
:attribute | Field name (human-readable if customized) | “email” or “Email Address” |
:input | The actual value submitted | ”not-an-email” |
:min | Minimum value from the rule | ”8” |
:max | Maximum value from the rule | ”255” |
:size | Required size from the rule | ”10” |
:other | The other field (for same, different, etc.) | “password” |
:values | Comma-separated accepted values (for in, not_in) | “draft, published, archived” |
:value | Value of another field (for required_if, etc.) | “credit card” |
The :input placeholder is useful when you want to echo back what the user typed:
'email.email' => 'The value ":input" does not look like an email address.',
// Renders: The value "john@" does not look like an email address.
Array Index Placeholders
When validating arrays, index placeholders pinpoint which element failed:
| Placeholder | Value | Output for items.2.name |
|---|---|---|
:index | Zero-based index | ”2” |
:position | One-based position | ”3” |
:ordinal-position | Ordinal position | ”3rd” |
For deeply nested arrays, prefixed variants exist: :second-index, :second-position, :third-index, :third-position, and so on.
'items.*.name.required' => 'Item #:position needs a name.',
// Renders: Item #3 needs a name.
'matrix.*.*.value.numeric' => 'Row :position, column :second-position must be a number.',
// Renders: Row 2, column 5 must be a number.
These placeholders eliminate the need for manual loops and string concatenation in error display logic – the validator inserts the correct index automatically.
Customizing the Attribute Name
By default, the :attribute placeholder renders the raw field name from the request. A field named shipping_address becomes “shipping address” (underscores replaced with spaces), and dob stays “dob”. Neither is suitable for production. Custom attribute names let you turn :attribute into something readable, and you can set them in three places: inline, in a Form Request, or in a language file.
Inline Attributes (Fourth Argument)
The fourth argument to Validator::make() maps field name aliases:
$validator = Validator::make($data, $rules, $messages, [
'dob' => 'date of birth',
'cc_number' => 'credit card number',
'shipping_address' => 'shipping address',
]);
Now a message like “The :attribute field is required.” renders as “The date of birth field is required.” instead of “The dob field is required.”
Form Request attributes() Method
class CheckoutRequest extends FormRequest
{
public function rules(): array
{
return [
'cc_number' => ['required', 'string'],
'cc_expiry' => ['required', 'date_format:m/y', 'after:today'],
'cc_cvv' => ['required', 'digits:3'],
];
}
public function attributes(): array
{
return [
'cc_number' => 'credit card number',
'cc_expiry' => 'expiration date',
'cc_cvv' => 'security code',
];
}
}
Language File Attributes
For an attribute mapping that applies everywhere, add it to the attributes array in lang/en/validation.php:
'attributes' => [
'email' => 'email address',
'dob' => 'date of birth',
'phone' => 'phone number',
'tos' => 'terms of service',
],
This is where you consolidate all custom field name mappings for the entire app, keeping individual validators clean.
Custom Values in Messages
Beyond field names, the values that appear in messages can also be customized. This lets you replace raw database or form values with readable labels. In lang/en/validation.php:
'values' => [
'payment_method' => [
'cc' => 'credit card',
'paypal' => 'PayPal',
'wire' => 'bank transfer',
],
],
A rule like required_if:payment_method,cc normally produces “The CVV field is required when payment method is cc.” With the mapping above, it reads “…when payment method is credit card.” The :value placeholder picks up the human-friendly string automatically.
The MessageBag API
Every time you call $validator->errors(), you get a MessageBag instance. The $errors variable in Blade is a ViewErrorBag that proxies method calls to the underlying MessageBag, so the API feels identical in both contexts. This is the single interface for reading, counting, and formatting errors regardless of where they originated.
Checking for Errors
// Any errors at all?
if ($errors->any()) {
// show error summary
}
// Specific field?
if ($errors->has('email')) {
// highlight the field
}
// How many total?
$count = $errors->count();
// Empty check (inverse of any)
if ($errors->isEmpty()) {
// no errors
}
Retrieving Messages
// First message for a field – most common in forms
$first = $errors->first('email');
// All messages for a field – returns array
$all = $errors->get('email');
// All messages for all fields – flat array
$everything = $errors->all();
Wildcard Retrieval for Arrays
For array validation, use the wildcard pattern to retrieve errors across all elements:
// All errors for every element in the items array
$itemErrors = $errors->get('items.*');
// Returns something like:
// [
// 'items.0.name' => ['The items.0.name field is required.'],
// 'items.2.sku' => ['The selected items.2.sku is invalid.'],
// ]
This is particularly useful for displaying a summary of array validation failures grouped by element rather than as a flat list.
Formatting Output
The first() and get() methods accept an optional format string:
// Wrap each message in a list item
$formatted = $errors->all('<li>:message</li>');
// First error for 'name' wrapped in a span
$html = $errors->first('name', '<span class="error">:message</span>');
Named Error Bags
A page with two forms – say a login form and a newsletter signup in the sidebar – creates a problem: when one form fails validation, both @error blocks trigger because they share the same default error bag. Named error bags isolate errors per form.
Storing Errors in a Named Bag
In a controller:
public function login(Request $request): RedirectResponse
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'password' => 'required|min:8',
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator, 'login') // 'login' bag
->withInput();
}
// ...
}
Or use the shorthand:
$request->validateWithBag('login', [
'email' => 'required|email',
'password' => 'required|min:8',
]);
Reading from a Named Bag in Blade
{{-- Summary for the login bag --}}
@if ($errors->login->any())
<ul>
@foreach ($errors->login->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
{{-- Per-field with the bag name as second argument --}}
@error('email', 'login')
<p class="text-red-600">{{ $message }}</p>
@enderror
Forgetting the bag name as the second argument to @error is a common source of bugs – the directive checks the default bag, finds nothing, and the error never appears on screen.
Validation Error Handling for JSON / API Responses
API consumers interact with validation errors differently than Blade templates. Understanding the JSON response format matters for frontend developers consuming your endpoints.
The Default 422 Response
When expectsJson() returns true (XHR requests, or requests with Accept: application/json), the response is a JSON object with a 422 status:
// No extra code needed – Laravel does this automatically
$request->validate([
'title' => 'required|string|max:255',
'status' => 'required|in:draft,published',
]);
The client receives:
{
"message": "The title field is required. (and 1 more error)",
"errors": {
"title": ["The title field is required."],
"status": ["The selected status is invalid."]
}
}
Frontend code should iterate the errors object:
try {
await axios.post('/api/articles', formData);
} catch (error) {
if (422 === error.response?.status) {
const fieldErrors = error.response.data.errors;
Object.entries(fieldErrors).forEach(([field, messages]) => {
// Display messages next to each field
showFieldError(field, messages[0]);
});
}
}
Customizing the Validation Error Response
To build a custom response structure – say you want to wrap errors in a different envelope or change the HTTP code – override failedValidation in a Form Request:
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class ApiStorePostRequest extends FormRequest
{
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'success' => false,
'code' => 'VALIDATION_FAILED',
'errors' => $validator->errors()->toArray(),
], 422));
}
}
This replaces the default error handling for that specific request. The HTTP code stays 422 unless you explicitly pass a different status.
Translating Validation Messages
For multilingual applications, validation message localization is handled through language files. Each locale gets its own copy of the messages.
Setting Up Locale Files
php artisan lang:publish
Then duplicate the en directory for each locale:
lang/
├── en/
│ └── validation.php
├── de/
│ └── validation.php
└── ja/
└── validation.php
Each file contains the same array keys with translated strings. To translate a validation message, write the localized string in the target locale file:
// lang/de/validation.php
return [
'required' => ':Attribute ist ein Pflichtfeld.',
'email' => ':Attribute muss eine gueltige E-Mail-Adresse sein.',
'min' => [
'string' => ':Attribute muss mindestens :min Zeichen lang sein.',
],
'custom' => [
'email' => [
'unique' => 'Diese E-Mail-Adresse ist bereits registriert.',
],
],
'attributes' => [
'email' => 'E-Mail-Adresse',
'password' => 'Passwort',
'name' => 'Name',
],
];
Laravel picks the locale from app()->getLocale(), which you set per-request – typically through a middleware that reads a URL segment, a cookie, or a header.
Translation in Rule Classes
When building custom rules, the $fail callback supports translation keys:
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (false === $this->isValid($value)) {
$fail('validation.custom_rule_name')->translate([
'attribute' => $attribute,
'limit' => $this->limit,
]);
}
}
The translate() method resolves the key from the current locale file and replaces any placeholders you pass – this is the recommended way to translate rule messages. All translations live in language files, never hardcoded in PHP classes.
Manually Controlling Validation Errors
Sometimes automatic error handling is not enough. You need to add errors based on external service calls, combine validation with business logic checks, or build multi-step wizards where errors accumulate across steps. The Validator facade gives you that control.
The after() Hook
The cleanest way to add custom checks that depend on other fields already being valid:
$validator = Validator::make($request->all(), [
'coupon_code' => 'required|string|max:20',
'cart_total' => 'required|numeric|min:0',
]);
$validator->after(function ($validator) use ($request) {
$coupon = Coupon::where('code', $request->coupon_code)->first();
if (null === $coupon) {
$validator->errors()->add('coupon_code', 'This coupon does not exist.');
return;
}
if ($coupon->isExpired()) {
$validator->errors()->add('coupon_code', 'This coupon expired on ' . $coupon->expires_at->format('M j, Y') . '.');
}
if ($request->cart_total < $coupon->minimum_spend) {
$validator->errors()->add(
'coupon_code',
"This coupon requires a minimum spend of \${$coupon->minimum_spend}."
);
}
});
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
The after hook fires after the rule-based loop finishes, regardless of whether any rules failed. If coupon_code is missing, the hook still runs, and $request->coupon_code will be null. The example above guards against this with the null === $coupon check and an early return. Always validate that dependent fields passed their rules before performing business-logic checks inside after.
Adding Errors Directly
For simpler cases – a single business logic check after validation:
$validator = Validator::make($request->all(), [
'username' => 'required|string|alpha_dash|max:30',
]);
if ($validator->passes()) {
$reserved = ['admin', 'root', 'system', 'api', 'www'];
if (in_array(strtolower($request->username), $reserved, true)) {
$validator->errors()->add('username', 'This username is reserved.');
}
}
if ($validator->errors()->isNotEmpty()) {
return back()->withErrors($validator)->withInput();
}
stopOnFirstFailure
When a form has many fields and you want the validator to bail after the first field that fails (not the first rule – the first field):
$validator = Validator::make($request->all(), [
'step' => 'required|integer|in:1,2,3',
'name' => 'required|string|max:100',
'email' => 'required|email',
'plan_id' => 'required|exists:plans,id',
])->stopOnFirstFailure();
This is different from the bail rule. bail stops the chain for a single field; stopOnFirstFailure() stops the entire validation run after any field fails. Useful for wizard forms where step 1 fields must all pass before step 2 fields are even worth checking.
Programmatic Redirects with withErrors
When you build a validator manually, you control the redirect and the error bag:
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
return redirect()
->route('orders.create')
->withErrors($validator, 'order') // named bag
->withInput();
}
This is the standard approach for controllers that cannot use the automatic exception-based flow.
Recipes for Common Scenarios
Multi-Step Form with Accumulated Errors
public function processWizard(Request $request): RedirectResponse|View
{
$step = (int) $request->input('step', 1);
$rulesPerStep = [
1 => ['name' => 'required|string', 'email' => 'required|email'],
2 => ['plan_id' => 'required|exists:plans,id'],
3 => ['payment_method' => 'required|in:cc,paypal,wire'],
];
$validator = Validator::make(
$request->all(),
$rulesPerStep[$step] ?? []
);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput();
}
if (3 === $step) {
// All steps passed – process the order
return $this->completeOrder($request);
}
return view('wizard.step' . ($step + 1));
}
Conditional Custom Messages per Locale
Combine Form Request messages() with translation helpers for a hybrid approach where some messages are translated and others are hardcoded:
public function messages(): array
{
return [
'title.required' => __('orders.title_required'),
'title.max' => __('orders.title_too_long', ['max' => 255]),
'items.required' => __('orders.empty_cart'),
'items.*.sku.exists' => __('orders.invalid_product'),
];
}
The __() helper reads from lang/{locale}/orders.php, giving translators a dedicated file per feature rather than dumping everything into validation.php.
API Error Envelope with Consistent Structure
For teams that need a uniform API envelope regardless of error type:
// bootstrap/app.php
use Illuminate\Validation\ValidationException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $e, $request) {
if ($request->expectsJson()) {
return response()->json([
'status' => 'error',
'type' => 'validation',
'message' => 'Input validation failed.',
'errors' => $e->errors(),
], $e->status);
}
});
})
This intercepts every validation error response going to API clients and wraps it in a consistent envelope. The $e->status preserves the 422 code, but you have the option to change it.
Displaying Errors for Dynamic Array Fields
A form where users add rows dynamically needs careful error display. Array index placeholders make this practical:
// In a Form Request or controller
public function rules(): array
{
return [
'guests' => ['required', 'array', 'min:1', 'max:10'],
'guests.*.name' => ['required', 'string', 'max:100'],
'guests.*.email' => ['required', 'email', 'distinct'],
];
}
public function messages(): array
{
return [
'guests.*.name.required' => 'Guest #:position needs a name.',
'guests.*.email.required' => 'Guest #:position needs an email.',
'guests.*.email.distinct' => 'Guest #:position has a duplicate email.',
];
}
@foreach ($guests as $i => $guest)
<fieldset>
<legend>Guest {{ $i + 1 }}</legend>
<input name="guests[{{ $i }}][name]" value="{{ old("guests.{$i}.name") }}">
@error("guests.{$i}.name")
<span class="error">{{ $message }}</span>
@enderror
<input name="guests[{{ $i }}][email]" value="{{ old("guests.{$i}.email") }}">
@error("guests.{$i}.email")
<span class="error">{{ $message }}</span>
@enderror
</fieldset>
@endforeach
For more on validating nested structures, see Arrays and JSON Validation.
Reusable Blade Error Component
Rather than repeating @error blocks in every form, extract a component:
{{-- resources/views/components/form-field.blade.php --}}
@props(['name', 'label', 'type' => 'text', 'bag' => 'default'])
<div class="mb-4">
<label for="{{ $name }}" class="block text-sm font-medium">
{{ $label }}
</label>
<input
type="{{ $type }}"
id="{{ $name }}"
name="{{ $name }}"
value="{{ old($name) }}"
@class([
'mt-1 block w-full rounded border px-3 py-2',
'border-red-500' => $errors->{$bag}->has($name),
'border-gray-300' => !$errors->{$bag}->has($name),
])
>
@error($name, $bag)
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
Usage:
<x-form-field name="email" label="Email Address" type="email" />
<x-form-field name="password" label="Password" type="password" bag="login" />
This component wraps error display, old value repopulation, and conditional styling in one place instead of repeating the same markup in every form. The bag prop supports named error bags for multi-form pages.
Common Pitfalls
A few traps come up repeatedly when working with validation error messages in Laravel.
$errors is not available in API routes. The $errors variable depends on the ShareErrorsFromSession middleware, which belongs to the web group. If you render a Blade view from an API route (uncommon but it happens), $errors will be undefined. Either add the middleware to the route or pass errors explicitly.
The lang directory does not exist by default. Fresh Laravel installations have no lang folder. Running php artisan lang:publish creates it. Without this step, editing lang/en/validation.php is impossible because the file does not exist yet.
Global message override vs. field-specific. Writing 'required' => 'Fill in this field' in the messages array changes the message for every field using the required rule in that validator. Almost always you want 'email.required' => 'Fill in your email' instead. The dot notation scopes the override to a single field.
@error shows only the first message. If a field fails two rules, @error('field') exposes only the first failure through $message. For all failures, use $errors->get('field') and loop.
Attribute names default to snake_case. The :attribute placeholder converts shipping_address to “shipping address” (underscores become spaces), but abbreviations like dob or sku remain as-is. Always define custom attributes for fields with non-obvious names.
Named bags need the second argument everywhere. If you store errors with withErrors($validator, 'login'), you must read them with $errors->login->first('email') in Blade and pass the bag name to @error('email', 'login'). Omitting the second argument silently checks the default bag, which is empty.
Testing validation errors. PHPUnit provides assertSessionHasErrors and assertJsonValidationErrors for web and API tests respectively. For API tests, also assert the status code (422) to ensure your custom handlers did not accidentally change it:
// Web test
$this->post('/register', ['email' => ''])
->assertSessionHasErrors(['email' => 'We need your email address.']);
// API test
$this->postJson('/api/register', ['email' => ''])
->assertStatus(422)
->assertJsonValidationErrors(['email']);
These assertions cover the most common cases. For checking exact message text in JSON responses, chain ->assertJson() with a nested path into the errors key.
Quick Reference
Error flow:
- Web request fails validation: redirect + flash errors to session
- JSON request fails validation: 422 response with
errorsobject
Three levels for custom messages:
- Inline: 2nd argument to
validate(), 3rd toValidator::make() - Form Request:
messages()method - Global:
lang/{locale}/validation.phpundercustomkey
Placeholder quick list: :attribute, :input, :min, :max, :size, :other, :values, :value, :index, :position, :ordinal-position
MessageBag essentials: any(), has('field'), first('field'), get('field'), all(), count(), isEmpty()
Related articles:
- Validation Quick Start – controller validation, Form Requests, Validator::make
- Form Requests – authorize, prepareForValidation, after hooks
- Custom Rules – Rule classes, closures, implicit rules
- Arrays and JSON – wildcard rules, nested validation
- Rules Reference – every built-in rule with examples
- Conditional Validation – sometimes, exclude_if, required_without