๐ Form Handling and Validation
Forms are where untrusted user input enters your application, so they are also where security and user experience are won or lost. Laravel gives you CSRF protection, a rich validation engine, and clean error feedback โ mostly for free.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Build Blade forms with CSRF protection, the old() helper, and method spoofing
- Read and filter submitted data through the Request object
- Validate input in a controller and in a dedicated Form Request class
- Write custom validation rules and display errors clearly in Blade
- Handle file uploads safely and follow the Post/Redirect/Get pattern
Estimated Time: 45โ60 minutes โข Difficulty: Intermediate
Hands-on: Build a validated registration form with a custom password-strength rule.
In This Lesson
The Form Lifecycle
Every form submission travels the same road: the browser sends a request, Laravel checks it is genuine, extracts and validates the data, does the work, and sends a response. Understanding this flow tells you exactly where each tool fits.
๐ก Analogy: Handling a form is like a post office processing mail. You confirm the sender is real (CSRF), check the envelope meets requirements (validation), open the contents (request data), act on the letter (business logic), and send a reply (redirect).
๐ Key Terms
CSRF: Cross-Site Request Forgery โ an attack where another site tricks a logged-in user's browser into submitting a request. A per-session token defeats it.
Post/Redirect/Get (PRG): after a successful POST, redirect to a GET page so refreshing doesn't resubmit the form.
Building a Blade Form
A well-built Laravel form uses four Blade tools together: @csrf for security, route() for the action URL, old() to repopulate fields after a failed submit, and @error to show messages inline.
<form action="{{ route('products.store') }}" method="POST">
@csrf
<div class="form-group">
<label for="name">Product Name</label>
<input type="text" name="name" id="name"
value="{{ old('name') }}"
class="@error('name') is-invalid @enderror">
@error('name')
<p class="invalid-feedback">{{ $message }}</p>
@enderror
</div>
<div class="form-group">
<label for="price">Price</label>
<input type="number" step="0.01" name="price" id="price"
value="{{ old('price') }}"
class="@error('price') is-invalid @enderror">
@error('price')
<p class="invalid-feedback">{{ $message }}</p>
@enderror
</div>
<button type="submit">Create Product</button>
</form>
Method spoofing for PUT / PATCH / DELETE
HTML forms can only send GET and POST. To hit a RESTful PUT, PATCH, or DELETE route, add the @method directive โ Laravel reads the hidden _method field and routes accordingly.
<form action="{{ route('products.update', $product) }}" method="POST">
@csrf
@method('PUT')
<!-- fields -->
<button type="submit">Update</button>
</form>
<form action="{{ route('products.destroy', $product) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit">Delete</button>
</form>
โ ๏ธ Forget @csrf and you get a 419
Every non-GET form needs @csrf. Omit it and Laravel rejects the request with HTTP 419 Page Expired. This is a feature, not a bug โ it is your CSRF shield working.
Accessing Request Data
Laravel injects an Illuminate\Http\Request object into your controller method. It offers precise, expressive ways to read input rather than touching PHP's raw $_POST.
public function store(Request $request)
{
$all = $request->all(); // everything
$name = $request->input('name'); // one field
$name = $request->name; // shorthand
$sort = $request->input('sort', 'created_at'); // with a default
if ($request->filled('email')) { /* present and not empty */ }
$creds = $request->only(['email', 'password']);
$payload = $request->except(['_token', '_method']);
$street = $request->input('address.street'); // nested / dot notation
}
๐ก Analogy: The Request object is a personal assistant who has already sorted the incoming paperwork. You ask for exactly the document you need instead of rummaging through the whole pile.
Validation Fundamentals
Never trust input. The simplest way to validate is $request->validate(). If any rule fails, Laravel automatically redirects back with the errors and the old input flashed to the session โ you write no branching code for the failure path.
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|confirmed',
'age' => 'nullable|integer|min:18',
'terms' => 'accepted',
]);
// Only reached when validation passes; $validated is safe to use.
User::create($validated);
return redirect()->route('dashboard')
->with('success', 'Account created!');
}
๐ก confirmed is a handy convention
The confirmed rule on password automatically checks that a password_confirmation field matches. Name the second field with the _confirmation suffix and it just works.
Custom messages
$request->validate([
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|confirmed',
], [
'email.unique' => 'That email is already registered.',
'password.min' => 'Use at least 8 characters.',
'password.confirmed'=> 'The passwords do not match.',
]);
๐ก Analogy: Validation is the bouncer at a club: each piece of data must meet the entry rules before it gets inside your application.
Rules & Custom Rules
Laravel ships with dozens of rules covering strings, numbers, dates, files, and arrays. A few representative examples:
| Category | Example rule string | Meaning |
|---|---|---|
| String | required|string|max:255 | Present, text, at most 255 chars |
| Number | required|numeric|min:0 | Present, numeric, non-negative |
| Date | required|date|after:today | A valid date later than today |
| Database | exists:categories,id | Value must match a row's id |
| Array item | tags.*|exists:tags,id | Each array element must exist |
| File | image|max:2048 | An image up to 2 MB |
A custom rule with a closure
$request->validate([
'password' => [
'required', 'min:8',
function ($attribute, $value, $fail) {
if (strtolower($value) === 'password') {
$fail('The '.$attribute.' is too obvious.');
}
},
],
]);
A reusable rule object
For rules you'll use across the app, generate a rule class with php artisan make:rule StrongPassword. Modern Laravel uses the ValidationRule interface:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class StrongPassword implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// At least one lower, one upper, one digit, min 8 chars.
if (! preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
$fail('The :attribute needs upper- and lower-case letters and a number.');
}
}
}
// Usage
$request->validate([
'password' => ['required', new StrongPassword],
]);
Form Request Classes
When validation grows, move it out of the controller into a Form Request. It bundles authorization and rules in one testable class, and it validates automatically the moment you type-hint it in a controller method.
php artisan make:request StoreProductRequest
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StoreProductRequest extends FormRequest
{
// Return false to reject the request with a 403.
public function authorize(): bool
{
return $this->user()->can('create', \App\Models\Product::class);
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'category_id' => 'required|integer|exists:categories,id',
'price' => 'required|numeric|min:0',
'image' => 'nullable|image|max:2048',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
];
}
public function messages(): array
{
return ['category_id.exists' => 'That category no longer exists.'];
}
// Tweak the data before rules run.
protected function prepareForValidation(): void
{
$this->merge(['slug' => Str::slug($this->name)]);
}
}
// The controller stays thin โ validation already happened.
public function store(StoreProductRequest $request)
{
$product = Product::create($request->validated());
$product->tags()->sync($request->input('tags', []));
return redirect()->route('products.show', $product)
->with('success', 'Product created!');
}
โ Why Form Requests
They separate concerns, put authorization and validation side by side, are reusable across controllers, and keep controllers focused on business logic. Reach for them as soon as a form has more than a handful of rules.
File Uploads
A form that uploads files needs enctype="multipart/form-data". Validate the file, then store it โ store() generates a safe, unique filename and returns the path to save in your database.
<form action="{{ route('products.store') }}" method="POST"
enctype="multipart/form-data">
@csrf
<input type="file" name="image">
<button type="submit">Upload</button>
</form>
public function store(Request $request)
{
$request->validate([
'image' => 'required|image|max:2048', // max 2 MB
]);
if ($request->hasFile('image')) {
// Stored under storage/app/public/products; returns e.g. "products/x7Kd.jpg"
$path = $request->file('image')->store('products', 'public');
Product::create([...$request->validated(), 'image_path' => $path]);
}
return back()->with('success', 'Uploaded!');
}
โ ๏ธ Validate before you trust a filename
Always validate uploads with rules like image or mimes:pdf,docx and a max: size. Never build a storage path from the user-supplied original filename directly, and expose files through the public disk (run php artisan storage:link once) rather than serving from arbitrary paths.
Worked Example: A Contact Form
This ties the pieces together: a Blade form, a Form Request for validation, a controller that follows Post/Redirect/Get, and a success message.
1 ยท The Blade view
@if (session('success'))
<div class="alert-success">{{ session('success') }}</div>
@endif
<form action="{{ route('contact.submit') }}" method="POST">
@csrf
<label for="name">Your Name</label>
<input id="name" name="name" value="{{ old('name') }}">
@error('name') <p class="invalid-feedback">{{ $message }}</p> @enderror
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}">
@error('email') <p class="invalid-feedback">{{ $message }}</p> @enderror
<label for="message">Message</label>
<textarea id="message" name="message">{{ old('message') }}</textarea>
@error('message') <p class="invalid-feedback">{{ $message }}</p> @enderror
<button type="submit">Send Message</button>
</form>
2 ยท The Form Request
class ContactFormRequest extends FormRequest
{
public function authorize(): bool
{
return true; // anyone may use the public contact form
}
public function rules(): array
{
return [
'name' => 'required|string|max:100',
'email' => 'required|email|max:255',
'message' => 'required|string|min:20|max:2000',
];
}
}
3 ยท The controller & routes
class ContactController extends Controller
{
public function show()
{
return view('contact');
}
public function submit(ContactFormRequest $request)
{
// Validation already passed. Send the mail.
Mail::to(config('mail.contact_address'))
->send(new ContactFormMail($request->validated()));
// Post/Redirect/Get: redirect so a refresh won't resend.
return redirect()->route('contact')
->with('success', 'Thanks! We will reply soon.');
}
}
// routes/web.php
Route::get('/contact', [ContactController::class, 'show'])->name('contact');
Route::post('/contact', [ContactController::class, 'submit'])->name('contact.submit');
What the user sees after a valid submit
Thanks! We will reply soon.
Hands-on Exercise
๐๏ธ Build a Validated Registration Form
Objective: Combine a Form Request, a custom rule, and error display.
Instructions:
- Create a registration form with
name,email,password,password_confirmation, and atermscheckbox. - Generate a
RegisterRequestForm Request with rules: name required, email required + unique, password confirmed + your customStrongPasswordrule, terms accepted. - Display inline errors with
@errorand repopulate every field withold()(except password). - On success, create the user and redirect with a flash message (Post/Redirect/Get).
๐ก Hint
The accepted rule is exactly what a "I agree to the terms" checkbox needs. Never echo old('password') back into the field โ it is a security and UX anti-pattern. Type-hint RegisterRequest $request in the controller so validation runs automatically.
โ Example solution (rules + controller)
// RegisterRequest::rules()
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => ['required', 'confirmed', new StrongPassword],
'terms' => 'accepted',
];
// Controller
public function store(RegisterRequest $request)
{
User::create($request->safe()->only(['name', 'email', 'password']));
return redirect()->route('login')
->with('success', 'Account created โ please log in.');
}
Best Practices
โ Do
- Always add
@csrfto non-GET forms and validate every input on the server. - Use
old()to preserve input and@errorto show clear, specific messages. - Move complex validation into Form Request classes and keep controllers thin.
- Follow Post/Redirect/Get so a page refresh never resubmits.
- Validate uploads by type and size before storing them.
โ ๏ธ Don't
- Don't rely on client-side (JavaScript) validation alone โ it is a convenience, not a guard.
- Don't mass-assign
$request->all()into a model; usevalidated()oronly(). - Don't repopulate password fields with
old(). - Don't trust the uploaded file's original name or MIME claim without validation.
Summary & Quiz
๐ Key Takeaways
- @csrf protects every non-GET form; @method spoofs PUT/PATCH/DELETE.
- $request->validate() redirects back with errors and old input automatically on failure.
- Form Request classes bundle authorization and validation for reuse and testability.
- Handle file uploads with
multipart/form-data, validation, andstore(); always follow Post/Redirect/Get.
๐ฏ Quick Quiz
Question 1: A POST form without @csrf returns HTTP 419. Why?
Question 2: When $request->validate() fails, what happens by default?
Question 3: What is the main advantage of a Form Request over validating inside the controller?
๐ Further Reading
- Laravel โ Validation
- Laravel โ HTTP Requests
- Laravel โ File Storage
- Laravel โ Blade Templates
๐ What's Next?
You can now capture and validate input securely. Next we'll identify who is submitting it โ building login, registration, and access control in the Laravel Authentication System.
๐ Great work!
Your forms are now secure, validated, and user-friendly.