Forms & Validation
Form requests, validation rules, errors and old input.
Every form is an attack surface and a source of bad data. Laravel's validation layer lets you declare what valid input looks like. When input fails those rules, the user is sent back with errors and their old values without you writing any of that plumbing.
In this lesson we build the "create task" form for the Projects & Tasks app. We start with inline validation, move it into a Form Request and then cover custom rules, conditional rules and authorisation.
The request round trip
When validation fails on a normal (non-JSON) request, Laravel throws a ValidationException. The framework turns it into a redirect back to the previous URL. Errors and the submitted input are flashed to the session for exactly one request:
For JSON requests (an Accept: application/json header, or any API route) the same exception becomes a 422 response with an errors object. You write one set of rules and it serves both a server-rendered form and a Vue front end.
Inline validation
The quickest way to start is $request->validate() in the controller. The real Api\TaskController::index() uses it to validate its query-string filters before they go anywhere near a query:
app/Http/Controllers/Api/TaskController.php (start of index())
$filters = $request->validate([
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'status' => ['nullable', Rule::in(['open', 'completed'])],
'project' => ['nullable', 'string', 'exists:projects,slug'],
'search' => ['nullable', 'string', 'max:100'],
]);
So /api/demo/tasks?priority=urgent returns a 422, not a broken query. For a form that creates something, the same call sits at the top of a store action:
Illustrative — not in this app (the demo API is read-only):
public function store(Request $request, Project $project)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'priority' => ['required', Rule::enum(TaskPriority::class)],
'due_on' => ['nullable', 'date', 'after_or_equal:today'],
'user_id' => ['nullable', 'exists:users,id'],
]);
$project->tasks()->create($data);
return redirect()->route('projects.show', $project)
->with('status', 'Task created.');
}
validate() returns only the validated keys. Anything else the client sends, such as project_id or is_admin, is dropped. That's your first line of defence against mass assignment.
The Blade form
Illustrative — not in this app. A server-rendered form for the same fields:
<form method="POST" action="{{ route('projects.tasks.store', $project) }}">
@csrf
<label for="title">Title</label>
<input id="title" name="title" value="{{ old('title') }}"
class="@error('title') is-invalid @enderror">
@error('title')
<p class="error">{{ $message }}</p>
@enderror
<label for="priority">Priority</label>
<select id="priority" name="priority">
@foreach (\App\Enums\TaskPriority::cases() as $priority)
<option value="{{ $priority->value }}" @selected(old('priority', 'medium') === $priority->value)>
{{ $priority->label() }}
</option>
@endforeach
</select>
<label for="due_on">Due</label>
<input id="due_on" type="date" name="due_on" value="{{ old('due_on') }}">
@error('due_on') <p class="error">{{ $message }}</p> @enderror
<button>Create task</button>
</form>
@csrfadds a hidden token. Without it, thePreventRequestForgerymiddleware (calledVerifyCsrfTokenbefore Laravel 13) rejects the POST with a 419.old('title')repopulates the field from the flashed input after a failed submission.@error('title')checks the shared$errorsbag and exposes$message.@selected()and@checked()are tidy alternatives to inline ternaries.
For an edit form, fall back to the model: old('title', $task->title). Add @method('PUT') because HTML forms can only send GET and POST.
Form Requests
Inline validation gets messy quickly. A Form Request is a dedicated class that holds authorisation, rules, custom messages and input preparation:
php artisan make:request StoreTaskRequest
This is the real Form Request behind the demo endpoint:
app/Http/Requests/StoreTaskRequest.php
namespace App\Http\Requests;
use App\Enums\TaskPriority;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
/**
* The demo endpoint is public and never persists, so anyone may validate.
*/
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'project_id' => ['required', 'integer', 'exists:projects,id'],
'title' => ['required', 'string', 'min:3', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::enum(TaskPriority::class)],
'due_on' => ['nullable', 'date', 'after_or_equal:today'],
'tag_ids' => ['array'],
'tag_ids.*' => ['integer', 'exists:tags,id'],
];
}
public function messages(): array
{
return [
'project_id.exists' => 'Pick one of the existing projects.',
'due_on.after_or_equal' => 'The due date cannot be in the past.',
];
}
}
In an app where users save tasks, authorize() is where you'd check permissions, for example return $this->user()->can('update', Project::find($this->input('project_id')));. You can also normalise input before the rules run. Illustrative — not in this app:
protected function prepareForValidation(): void
{
$this->merge(['title' => trim((string) $this->input('title'))]);
}
public function attributes(): array
{
return ['project_id' => 'project']; // "The project field is required."
}
The controller shrinks to the happy path. Laravel resolves the request from the container, authorises it and validates it before your method body runs. Here's the real demo endpoint, Api\TaskController::validateOnly(), which returns what was validated instead of saving it:
app/Http/Controllers/Api/TaskController.php
/**
* Run the real StoreTaskRequest rules without saving anything.
*/
public function validateOnly(StoreTaskRequest $request): JsonResponse
{
return response()->json([
'valid' => true,
'data' => $request->validated(),
]);
}
A saving version would be just as short. Illustrative — not in this app:
public function store(StoreTaskRequest $request): RedirectResponse
{
$task = Task::create($request->safe()->except('tag_ids'));
$task->tags()->sync($request->validated('tag_ids', []));
return to_route('projects.show', $task->project)->with('status', 'Task created.');
}
If authorize() returns false, the user gets a 403. $request->safe() returns a ValidatedInput object with only(), except() and merge() helpers.
Where should validation live? Put it in Form Requests, for three reasons. They keep controllers thin, the same request class works for web and API endpoints, and authorisation runs in the same place. Use prepareForValidation() for normalising input, and rely on $request->validated() / safe() as the guard against mass assignment.
Try the rules
This playground runs a small JavaScript re-implementation of a few Laravel rules against the input, so you can see how rule order, nullable and bail affect the error list:
Useful rules and patterns
The patterns in this section are illustrative — not in this app — but they're the rules you'll reach for most often in real projects.
Uniqueness that ignores the current record
// Updating a project: name must be unique per owner, but not clash with itself
'name' => [
'required',
Rule::unique('projects')
->where('user_id', $this->user()->id)
->ignore($this->route('project')),
],
Conditional rules
// High-priority tasks must have a due date and a description
'due_on' => ['required_if:priority,high', 'nullable', 'date'],
'description' => [Rule::requiredIf(fn () => $this->input('priority') === 'high'), 'nullable', 'string'],
// Rules that only apply sometimes
public function withValidator($validator): void
{
$validator->sometimes('estimate_hours', 'required|integer|min:1', function ($input) {
return $input->priority === 'high';
});
}
bail, nullable and sometimes
| Modifier | Effect |
|---|---|
bail |
Stop checking this field after its first failure |
nullable |
null / empty is allowed. Other rules are skipped when empty |
sometimes |
Only validate the field if it's present in the input (great for PATCH) |
required |
Must be present and not empty |
present |
Must be present, may be empty |
Enums
PHP backed enums make fields like priority type-safe end to end. This is the real enum from this app:
app/Enums/TaskPriority.php
namespace App\Enums;
enum TaskPriority: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
public function label(): string
{
return ucfirst($this->value);
}
}
// Rule: rejects anything that isn't a valid case value
'priority' => ['required', Rule::enum(TaskPriority::class)],
// Model cast (in Task::casts()): $task->priority is now a TaskPriority instance
'priority' => TaskPriority::class,
// So comparisons are type-safe
if ($task->priority === TaskPriority::High) { /* ... */ }
Custom validation rules
When a rule encodes business logic, give it a class. Illustrative — not in this app (projects don't have deadlines here):
php artisan make:rule WithinProjectDeadline
namespace App\Rules;
use App\Models\Project;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class WithinProjectDeadline implements ValidationRule
{
public function __construct(private Project $project) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($this->project->deadline && $value > $this->project->deadline->toDateString()) {
$fail("The :attribute must be on or before the project deadline ({$this->project->deadline->format('j M')}).");
}
}
}
// usage
'due_on' => ['nullable', 'date', new WithinProjectDeadline($this->route('project'))],
Validation in a JSON / Vue front end
The same Form Request returns this from the demo endpoint. This is real output for {"title": "x", "priority": "urgent", "due_on": "2020-01-01"}:
{
"message": "The project id field is required. (and 3 more errors)",
"errors": {
"project_id": ["The project id field is required."],
"title": ["The title field must be at least 3 characters."],
"priority": ["The selected priority is invalid."],
"due_on": ["The due date cannot be in the past."]
}
}
Notice the custom due_on.after_or_equal message from messages() and the default messages for everything else. A valid payload gets back exactly what $request->validated() returns: the same keys you sent, minus anything without a rule.
// project_id is the id of an existing project, e.g. website-relaunch (ids differ per environment)
{ "valid": true, "data": { "project_id": …, "title": "Write release notes", "priority": "high", "due_on": "2026-11-01" } }
Because bootstrap/app.php tells Laravel to render JSON for every api/* route, even a plain HTML form post gets the 422 JSON response rather than a redirect. DemoApiTest::test_validate_endpoint_returns_json_errors_even_for_plain_form_posts checks exactly that.
Try the real endpoint
This form posts to POST /api/demo/tasks/validate in this app. That route runs the real App\Http\Requests\StoreTaskRequest and returns either Laravel's actual 422 error bag or {"valid": true, "data": ...}, which is exactly what $request->validated() would give the controller. Nothing is saved. Submit it empty first:
On the client, map errors straight onto your fields, as the demo above does (with fetch; this is the same idea with axios):
try {
await axios.post(`/api/projects/${projectId}/tasks`, form);
} catch (e) {
if (e.response?.status === 422) {
errors.value = e.response.data.errors; // { title: [...], ... }
} else {
throw e;
}
}
This site uses the same idea. The Filament admin at /admin that edits these pages is built on the same validator. A Filament field such as TextInput::make('slug')->required()->unique(ignoreRecord: true) compiles down to the Laravel rules required and unique:pages,slug,{id}.
File uploads
Uploaded files are validated with the same validator, using rules such as File::types([...])->max(...) or image, mimes and max (in kilobytes). File Uploads & Storage covers them in full, with a live endpoint you can send a real file to.
Key takeaways
$request->validate()and Form Requests both return only the validated data. Persist that, never$request->all().- Failed validation redirects back with
$errorsandold()input for web requests, or returns a 422 JSON response for API requests. - Form Requests group authorisation, input preparation, rules and messages, which keeps controllers thin.
- Use
Rule::unique()->ignore(),Rule::exists()->where(),Rule::enum()and conditional rules for real-world constraints. - Wrap business rules in
ValidationRuleclasses so they're reusable and unit-testable.