Validation and Form Requests
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in
the Bookshelf application used across this guide. Validation is where untrusted input becomes data the application
can rely on; the plain-PHP version of the same job, with filter_var() and hand-written checks, is on
Forms, Validation and Output Escaping. Reading the
input itself is on Requests, Input and Responses.
Validating in a controller
$request->validate([...]) returns the validated data or throws Illuminate\Validation\ValidationException, which
Laravel turns into one of two responses: for a normal form post, a 302 redirect back with the errors and the old
input flashed to the session; for a request that expects JSON, a 422 with the messages:
<?php
// app/Http/Controllers/BookReviewController.php (excerpt)
use Illuminate\Http\Request;
function store(Request $request)
{
$data = $request->validate([
'rating' => 'required|integer|between:1,5',
'body' => ['required', 'string', 'max:2000'], // string or array syntax
]);
// $data holds only rating and body, validated
}
Rules run left to right on each field and all fields are checked, so the user sees every problem at once. bail
stops the remaining rules of one field after its first failure (useful before an expensive exists query).
nullable lets an optional field be null, which matters because ConvertEmptyStringsToNull turns empty form
fields into null before validation.
Displaying errors and repopulating the form
After the redirect, every view receives an $errors bag (shared by the web group’s ShareErrorsFromSession).
@error('field') renders its block with $message when that field failed, and old('field') returns the flashed
input:
{{-- resources/views/catalogue/create.blade.php --}}
<form method="POST" action="/catalogue/books">
@csrf
@if ($errors->any())
<p class="summary">{{ $errors->count() }} problem(s) to fix</p>
@endif
<label for="title">Title</label>
<input id="title" name="title" value="{{ old('title') }}" @class(['invalid' => $errors->has('title')])>
@error('title')
<p class="error">{{ $message }}</p>
@enderror
<label for="isbn">ISBN</label>
<input id="isbn" name="isbn" value="{{ old('isbn') }}">
@error('isbn')
<p class="error">{{ $message }}</p>
@enderror
<button>Add book</button>
</form>
$ curl -s -c jar -b jar -o /dev/null localhost:8000/catalogue/books/create
$ curl -s -c jar -b jar -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Referer: http://localhost:8000/catalogue/books/create' -d 'title=Dawn&isbn=978-0-446-67697-0&format=paperback&author_id=1' localhost:8000/catalogue/books
302 http://localhost:8000/catalogue/books/create
$ curl -s -c jar -b jar localhost:8000/catalogue/books/create | grep -E 'summary|error|value='
<input type="hidden" name="_token" value="…" autocomplete="off">
<p class="summary">1 problem(s) to fix</p>
<input id="title" name="title" value="Dawn" class="">
<input id="isbn" name="isbn" value="978-0-446-67697-0">
<p class="error">The isbn checksum is wrong. Check for a mistyped digit.</p>
{{ }} escapes both the message and the old value, so a malicious value cannot inject markup. The JSON shape of a
422 is {"message": "...", "errors": {"field": ["..."]}}, shown in the next section.
Form Requests
php artisan make:request StoreBookRequest creates a class in app/Http/Requests that holds the authorization
check, the rules and their messages. Type-hinting it in an action validates the request before the action runs:
<?php
// app/Http/Requests/StoreBookRequest.php
namespace App\Http\Requests;
use App\Enums\Format;
use App\Rules\Isbn13;
use Closure;
use Illuminate\Foundation\Http\Attributes\FailOnUnknownFields;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Validator;
#[FailOnUnknownFields] // Laravel 13: reject keys with no rule
class StoreBookRequest extends FormRequest
{
public function authorize(): bool
{
return true; // make:request generates "return false"
}
protected function prepareForValidation(): void
{
$this->merge(['isbn' => str_replace(' ', '-', trim((string) $this->input('isbn')))]);
}
public function rules(): array
{
return [
'author_id' => ['required', 'integer', Rule::exists('authors', 'id')],
'title' => ['required', 'string', 'max:200'],
'isbn' => ['required', 'string', 'max:17', new Isbn13, Rule::unique('books', 'isbn')],
'published_year' => ['nullable', 'integer', 'between:1450,'.now()->year],
'format' => ['required', Rule::enum(Format::class)],
'ebook_url' => ['exclude_unless:format,ebook', 'required', 'url:https'],
'tags' => ['array', 'max:5'],
'tags.*' => ['string', 'distinct', 'max:30'],
'cover' => ['nullable', File::image()->max('2mb')->dimensions(Rule::dimensions()->maxWidth(2000))],
'blurb' => ['nullable', 'string', function (string $attribute, mixed $value, Closure $fail) {
if (str_contains(strtolower($value), 'lorem ipsum')) {
$fail("The {$attribute} still contains placeholder text.");
}
}],
];
}
public function after(): array
{
return [
function (Validator $validator) {
if ($this->input('format') === 'hardcover' && $this->integer('published_year') > 0
&& $this->integer('published_year') < 1820) {
$validator->errors()->add('format', 'Hardcover editions are not catalogued before 1820.');
}
},
];
}
public function messages(): array
{
return ['isbn.unique' => 'That ISBN is already on the shelf.'];
}
public function attributes(): array
{
return ['author_id' => 'author', 'published_year' => 'year'];
}
}
<?php
// app/Http/Controllers/CatalogueBookController.php
namespace App\Http\Controllers;
use App\Http\Requests\StoreBookRequest;
use App\Models\Book;
use Illuminate\Http\RedirectResponse;
class CatalogueBookController extends Controller
{
public function store(StoreBookRequest $request): RedirectResponse // validated before this line runs
{
$book = Book::create($request->safe()->except(['format', 'ebook_url', 'tags', 'cover', 'blurb']));
return to_route('books.show', $book)->with('status', 'Book added');
}
}
Three JSON requests against POST /catalogue/books:
$ curl -s -H 'Accept: application/json' -d 'title=' localhost:8000/catalogue/books
{"message":"The author field is required. (and 3 more errors)","errors":{"author_id":["The author field is required."],
"title":["The title field is required."],"isbn":["The isbn field is required."],"format":["The format field is required."]}}
$ curl -s -H 'Accept: application/json' -d 'author_id=99&title=Wild Seed&isbn=978 0 446 67697 0&format=hardcover&published_year=1780&blurb=Lorem ipsum&shelf=A3&tags[]=sf&tags[]=sf' localhost:8000/catalogue/books
{"message":"The selected author is invalid. (and 6 more errors)","errors":{"author_id":["The selected author is invalid."],
"isbn":["The isbn checksum is wrong. Check for a mistyped digit."],"blurb":["The blurb still contains placeholder text."],
"tags.0":["The tags.0 field has a duplicate value."],"tags.1":["The tags.1 field has a duplicate value."],
"format":["Hardcover editions are not catalogued before 1820."],"shelf":["The shelf field is prohibited."]}}
$ curl -s -H 'Accept: application/json' -d 'author_id=1&title=Wild Seed&isbn=978-0-446-67697-7&format=ebook&ebook_url=http://example.com/ws' localhost:8000/catalogue/books
{"message":"That ISBN is already on the shelf. (and 1 more error)","errors":{"isbn":["That ISBN is already on the shelf."],
"ebook_url":["The ebook url field must be a valid URL."]}}
What the run shows:
-
attributes()renamedauthor_idto "author" in the messages;tags.0was not renamed (add'tags.*' => 'tag'). -
prepareForValidation()turned978 0 446 67697 7into978-0-446-67697-7before the rules saw it, so the checksum and theuniquecheck work on the stored format. -
The
after()hook ran and added its error even though other rules had already failed, so it must not assume the input is valid; check$validator->failed()first if it depends on other fields. -
#[FailOnUnknownFields](Laravel 13) answered the unexpectedshelffield with "prohibited". It is a defence against mass assignment of keys the form never had;FormRequest::failOnUnknownFields()inAppServiceProvider::boot()turns it on for every form request. -
exclude_unless:format,ebookremovedebook_urlfrom the rules and the validated data unless the format isebook; when it is,url:httpsrefused anhttp://URL. -
make:requestgeneratesauthorize()returningfalse, which answers every request with403 This action is unauthorized.until it is changed. Returntrueand authorize in a policy, or call$this->user()->can(...)here.
Other Form Request attributes in Laravel 13 are #[StopOnFirstFailure], #[ErrorBag('login')],
#[RedirectTo('/path')] and #[RedirectToRoute('name')]; older applications set the $stopOnFirstFailure,
$errorBag and $redirect properties instead.
Working with validated input
$request->validated() returns only the fields that have rules, as an array; $request->safe() returns the same data
as a ValidatedInput object with only(), except(), merge() and collect(). Using one of them instead of
$request->all() is what makes mass assignment safe: a field without a rule never reaches create(). The manual
validator below also shows validateWithBag(), nested arrays and conditional rules:
<?php
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
$data = [
'loans' => [
['isbn' => '978-0-446-67697-7', 'days' => 14],
['isbn' => '', 'days' => 90],
],
'member_type' => 'staff',
'note' => 'urgent',
];
$validator = Validator::make($data, [
'loans' => ['required', 'array', 'min:1'],
'loans.*.isbn' => ['required', 'exists:books,isbn'],
'loans.*.days' => ['integer', Rule::when(fn ($input) => $input->member_type !== 'staff', 'max:28', 'max:60')],
'member_type' => ['required', Rule::in(['student', 'staff'])],
], [
'loans.*.isbn.required' => 'Loan #:position needs an ISBN.',
'loans.*.days.max' => 'Loan #:position is too long (:max days at most).',
]);
$validator->sometimes('note', 'max:3', fn ($input) => $input->member_type === 'student');
var_dump($validator->fails());
echo json_encode($validator->errors()->toArray()), PHP_EOL;
echo json_encode($validator->errors()->first('loans.1.days')), PHP_EOL;
$ok = Validator::make(['title' => 'Dawn', 'extra' => 'x'], ['title' => 'required|string']);
echo json_encode($ok->validated()), ' ', json_encode($ok->safe()->only('title')), PHP_EOL;
try {
Validator::make(['title' => ''], ['title' => 'required'])->validateWithBag('import');
} catch (Illuminate\Validation\ValidationException $e) {
echo $e->errorBag, ' ', json_encode($e->errors()), ' ', $e->status, PHP_EOL;
}
$ php artisan tinker --execute "$(cat validate-loans.php)"
bool(true)
{"loans.1.isbn":["Loan #2 needs an ISBN."],"loans.1.days":["Loan #2 is too long (60 days at most)."]}
"Loan #2 is too long (60 days at most)."
{"title":"Dawn"} {"title":"Dawn"}
import {"title":["The title field is required."]} 422
Source: Laravel — Manually Creating Validators, Validating Nested Array Input and Conditionally Adding Rules
:position is the 1-based position and :index the 0-based key, so messages can say "Loan #2" rather than
loans.1.days. Rule::when() picked max:60 for staff, and sometimes() did not apply max:3 to the note because
the member is not a student. A named error bag (import) keeps two forms on one page apart: the view reads
$errors->import->first('title'), and @error('title', 'import') targets it.
The rules by category
The full list has over a hundred rules; the ones most applications use, grouped:
| Category | Rules |
|---|---|
Presence |
|
Types |
|
Sizes |
|
Strings |
|
Comparisons and dates |
|
Database |
|
Files |
|
Two traps from the reference: size, min and max mean characters for strings, the value for numbers (only
with integer or numeric present), items for arrays and kilobytes for files, so max:5 without a type rule on a
numeric string counts characters; and unique on an update form needs Rule::unique('books')->ignore($book) or the
record conflicts with itself.
File and Password rule objects
The fluent File and Password objects replace long strings of file and password rules:
<?php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Rules\Password;
$rule = Password::min(12)->letters()->mixedCase()->numbers();
foreach (['short1A', 'longenoughbutnodigits', 'Longenough2026x'] as $candidate) {
$v = Validator::make(['new_password' => $candidate, 'new_password_confirmation' => $candidate],
['new_password' => ['required', 'confirmed', $rule]]);
echo $candidate, ' => ', json_encode($v->errors()->get('new_password')), PHP_EOL;
}
$big = UploadedFile::fake()->image('cover.jpg', 2400, 1600)->size(500);
$v = Validator::make(['cover' => $big], ['cover' => [File::image()->max('2mb')->dimensions(Rule::dimensions()->maxWidth(2000))]]);
echo json_encode($v->errors()->get('cover')), PHP_EOL;
$text = UploadedFile::fake()->create('cover.jpg', 10, 'text/plain');
echo json_encode(Validator::make(['cover' => $text], ['cover' => [File::image()]])->errors()->get('cover')), PHP_EOL;
short1A => ["The new password field must be at least 12 characters."]
longenoughbutnodigits => ["The new password field must contain at least one uppercase and one lowercase letter.","The new password field must contain at least one number."]
Longenough2026x => []
["The cover field has invalid image dimensions."]
["The cover field must be an image."]
Source: Laravel — Validating Files and Validating Passwords
The text file named cover.jpg was refused: image checks the MIME type detected from the content, not the
extension. Password::defaults(fn () => ...) in AppServiceProvider::boot() sets one policy that
Password::defaults() then returns everywhere. ->uncompromised() checks the Have I Been Pwned range API with
k-anonymity (only the first five characters of the SHA-1 hash leave the server); it needs outbound HTTPS, so it was
not run here. Current guidance (NIST SP 800-63B) favours length and breach checks over composition rules, which is
why the starter kits use Password::defaults() rather than a list of character classes. Uploads in general are on
File Uploads and Images.
Custom rules and messages
A rule used in more than one place becomes a class (php artisan make:rule Isbn13) implementing ValidationRule;
one used once can stay a closure, like the blurb check above. $fail() takes the message, and ->translate()
looks it up in the language files:
<?php
// app/Rules/Isbn13.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/** ISBN-13 checksum: weights 1,3,1,3,...; the sum must be a multiple of 10. */
class Isbn13 implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$digits = preg_replace('/[^0-9]/', '', (string) $value);
if (strlen($digits) !== 13) {
$fail('The :attribute must have 13 digits.');
return;
}
$sum = 0;
foreach (str_split($digits) as $i => $d) {
$sum += (int) $d * ($i % 2 === 0 ? 1 : 3);
}
if ($sum % 10 !== 0) {
$fail('validation.isbn_checksum')->translate(); // message from lang/en/validation.php
}
}
}
<?php
// lang/en/validation.php (after php artisan lang:publish, or created by hand)
// Only the keys this application adds or overrides; the rest come from the framework.
return [
'isbn_checksum' => 'The :attribute checksum is wrong. Check for a mistyped digit.',
];
A new application has no lang directory: the default messages live in the framework, and lang:publish copies
them out for editing. A file that defines only some keys is merged with the framework’s, as the single key above
shows. Translating messages into other languages is on
Localization. Like most rules, a rule object is skipped when the field is
absent or empty; make:rule --implicit generates one with public $implicit = true, which makes it run anyway.
Live validation with Precognition
Precognition lets the front end ask the server to validate a form while the user types, with the same Form
Request and no duplicated rules in JavaScript. The precognitive middleware (HandlePrecognitiveRequests) runs the
route’s middleware and resolves the Form Request, then stops before the controller:
<?php
// routes/catalogue.php
use App\Http\Controllers\CatalogueBookController;
use Illuminate\Support\Facades\Route;
Route::post('/catalogue/books', [CatalogueBookController::class, 'store'])
->middleware('precognitive')
->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class); // only so curl can post
$ curl -s -D - -o /dev/null -H 'Precognition: true' -H 'Precognition-Validate-Only: title' -H 'Accept: application/json' -d 'title=Dawn' localhost:8000/catalogue/books
HTTP/1.1 204 No Content
Precognition-Success: true
Precognition: true
Vary: Precognition
$ curl -s -H 'Precognition: true' -H 'Precognition-Validate-Only: title,isbn' -H 'Accept: application/json' -d 'title=&isbn=123' localhost:8000/catalogue/books
{"message":"The title field is required. (and 1 more error)","errors":{"title":["The title field is required."],"isbn":["The isbn must have 13 digits."]}}
Source: Laravel — Precognition
Precognition-Validate-Only limits the check to the fields the user has touched, so the missing author and format
were not reported. On the client, the laravel-precognition-vue, -react and -alpine packages (and Inertia’s
useForm with .validate()) send these requests on change, debounced. Code that must not run during a
precognitive request, such as a rate-limit hit, can check $request->isPrecognitive(). Livewire validates on the
server by design and does not need it (Livewire).
Testing validation
<?php
// tests/Feature/StoreBookRequestTest.php
use App\Models\Author;
use App\Models\Book;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function validBook(array $overrides = []): array
{
return array_merge([
'author_id' => Author::factory()->create()->id,
'title' => 'Wild Seed',
'isbn' => '978 0 446 67697 7',
'format' => 'paperback',
'published_year' => 1980,
], $overrides);
}
it('stores a valid book with the normalised ISBN', function () {
$this->post('/catalogue/books', validBook())
->assertSessionHasNoErrors()
->assertRedirect();
expect(Book::sole()->isbn)->toBe('978-0-446-67697-7');
});
it('rejects a bad checksum, a duplicate and unknown fields', function (array $input, string $field) {
Book::factory()->create(['isbn' => '978-0-446-67697-7']);
$this->postJson('/catalogue/books', validBook($input))->assertInvalid([$field]);
})->with([
'checksum' => [['isbn' => '978-0-446-67697-0'], 'isbn'],
'duplicate' => [['isbn' => '978-0-446-67697-7'], 'isbn'],
'unknown field' => [['shelf' => 'A3'], 'shelf'],
'ebook without https URL' => [['format' => 'ebook', 'ebook_url' => 'http://example.com'], 'ebook_url'],
]);
it('answers a precognitive request without running the controller', function () {
$this->withPrecognition()
->post('/catalogue/books', ['title' => 'Dawn'], ['Precognition-Validate-Only' => 'title'])
->assertSuccessfulPrecognition();
expect(Book::count())->toBe(0);
});
$ php artisan test --filter=StoreBookRequest
PASS Tests\Feature\StoreBookRequestTest
✓ it stores a valid book with the normalised ISBN 0.21s
✓ it rejects a bad checksum, a duplicate and unknown fields with data… 0.02s
✓ it rejects a bad checksum, a duplicate and unknown fields with data… 0.01s
✓ it rejects a bad checksum, a duplicate and unknown fields with data… 0.01s
✓ it rejects a bad checksum, a duplicate and unknown fields with data… 0.01s
✓ it answers a precognitive request without running the controller 0.20s
Tests: 6 passed (16 assertions)
Duration: 0.52s
Source: Laravel — assertInvalid and Pest — Datasets
A Pest dataset runs one test per invalid case, which keeps a test per rule cheap to write.
Where older books and tutorials are outdated
-
$this->validate($request, [...])from theValidatesRequeststrait (Stauffer ch. 7) needs that trait on the base controller, which new applications (Laravel 11) no longer have; use$request->validate()or a Form Request. -
Rule classes implementing
Rulewithpasses()andmessage()(Stauffer ch. 7) are replaced byValidationRulewith a singlevalidate()method and$fail(Laravel 10);make:rulegenerates the new form. -
resources/langmoved tolang/(Laravel 9) and is absent untilphp artisan lang:publish(Laravel 10). -
$request->all()passed tocreate()after validating (Stauffer ch. 7 warns about mass assignment) should bevalidated()orsafe(); Laravel 13 adds#[FailOnUnknownFields]to refuse extra keys outright. -
Duplicating rules in JavaScript for live feedback is unnecessary with Precognition (Laravel 10).
-
Character-class password rules (an upper case letter, a digit, a symbol) are discouraged by NIST SP 800-63B in favour of length and breach checks;
Password::defaults()lets the application choose.