π Form Handling in Laravel
Forms are the front door through which users hand data to your application. In this lesson you'll build secure Laravel forms end to end β wiring up routes, protecting against CSRF, reading input off the Request object, repopulating fields after errors, and safely storing uploaded files.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Build a Blade form with CSRF protection, method spoofing, and old-input repopulation
- Wire up GET and POST routes to a controller that displays and processes a form
- Retrieve form data with the
Requestobject methods (input,only,filled,has) - Handle file uploads securely and store them on a disk
- Apply form best practices that make submissions safe, resilient, and user-friendly
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a complete "create post" form β route, controller, Blade view, and file upload.
In This Lesson
The Form Round-Trip
A form is a two-way bridge between the user and your data. The user fills fields in the browser and submits; the browser packages those fields into an HTTP request and sends them to a route; a controller reads, validates, and stores the data; then the app responds β usually a redirect with a flash message. Understanding this round-trip is the key to every form you'll ever write.
Notice the two exits from the "Valid?" gate. A well-built form handles both: the happy path (save and confirm) and the sad path (send the user back with their input preserved and clear error messages). Laravel gives you first-class tools for each, and we'll use every one of them here.
π Key Terms
CSRF: Cross-Site Request Forgery β an attack where a malicious page tricks a logged-in user's browser into submitting a request. Laravel blocks it with a per-session token.
Old input: the values a user typed, flashed to the session so a redirected form can refill itself.
Flash message: data stored in the session for exactly one subsequent request β perfect for "Saved!" confirmations.
Building a Form in Blade
Every Laravel form starts as HTML in a Blade template. The anatomy is simple: an action that points at a named route, a method, the @csrf directive, named inputs, and the old() helper to survive validation failures.
{{-- resources/views/contact.blade.php --}}
<form action="{{ route('contact.submit') }}" method="POST">
@csrf
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ old('name') }}">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{ old('email') }}">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5">{{ old('message') }}</textarea>
</div>
<button type="submit">Send Message</button>
</form>
Each piece earns its place:
route('contact.submit')β always target a named route, never a hardcoded URL. Rename the URL later and the form keeps working.method="POST"β form submissions that change data should never be GET (GET requests get cached, bookmarked, and prefetched).@csrfβ the security token, covered in the next section.name="..."β the key you'll use on the server to read the value. Noname, no data.old('name')β pulls the previously submitted value back into the field after a failed validation redirect.
β οΈ The name attribute is not optional
A beautiful input with no name attribute sends nothing to the server. If a field mysteriously never arrives in $request->all(), this is almost always why.
CSRF & Method Spoofing
Cross-Site Request Forgery protection
The @csrf directive expands into a hidden field carrying a token that Laravel's middleware verifies on every state-changing request:
<!-- What @csrf renders -->
<input type="hidden" name="_token" value="a-long-random-per-session-token">
Think of it as a tamper-evident seal. Because a malicious third-party page can't read your session's token, it can't forge a valid submission. Forget the @csrf directive and Laravel returns a 419 Page Expired response β a classic "why won't my form submit?" moment.
Spoofing PUT, PATCH, and DELETE
HTML forms only speak GET and POST. To hit a RESTful update or destroy route, add the @method directive, which injects a hidden _method field Laravel reads to override the verb:
<form action="{{ route('users.update', $user) }}" method="POST">
@csrf
@method('PUT')
<!-- form fields -->
<button type="submit">Update</button>
</form>
The browser still sends a POST; Laravel treats it as a PUT when routing. This is exactly what Route::resource expects for its update endpoints.
Processing the Submission
A form needs two routes: one GET to display it, one POST to receive it. In Laravel 11, web routes live in routes/web.php:
<?php
// routes/web.php
use App\Http\Controllers\ContactController;
Route::get('/contact', [ContactController::class, 'show'])->name('contact.show');
Route::post('/contact', [ContactController::class, 'submit'])->name('contact.submit');
The controller displays the view and, on submission, reads the input, does the work, and redirects with a flash message:
<?php
// app/Http/Controllers/ContactController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Mail\ContactMessage;
use Illuminate\Support\Facades\Mail;
class ContactController extends Controller
{
public function show()
{
return view('contact');
}
public function submit(Request $request)
{
// Validate first β an invalid request never reaches the logic below
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email',
'message' => 'required|string|min:10',
]);
// Do the work (send an email, save a record, etc.)
Mail::to('team@example.com')->send(new ContactMessage($validated));
// Redirect with a one-request flash message
return redirect()
->route('contact.show')
->with('success', 'Your message has been sent!');
}
}
The redirect()->with() pattern follows the Post/Redirect/Get convention: after a successful POST, redirect so a browser refresh doesn't resubmit the form. Show the flash message in Blade:
@if (session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
The Request Object
The Illuminate\Http\Request object is your window onto everything the user sent. Type-hint it on a controller method and Laravel injects it automatically. Prefer validate() for real work, but these accessors are invaluable for inspecting input:
public function submit(Request $request)
{
// A single field
$name = $request->input('name');
// A single field with a fallback default
$name = $request->input('name', 'Guest');
// Only these fields (great for building a whitelist)
$credentials = $request->only(['email', 'password']);
// Everything except these
$data = $request->except(['_token', '_method']);
// Presence checks
if ($request->has('newsletter')) { /* the key exists */ }
if ($request->filled('name')) { /* exists AND not empty */ }
// Everything as an array (use with care β never mass-assign this raw)
$all = $request->all();
}
β οΈ Never mass-assign raw request data
Passing $request->all() straight into Model::create() is a mass-assignment vulnerability β a user could set columns you never intended (like is_admin). Always pass the output of validate() instead, which returns only the fields you named.
Request metadata
Beyond input, the request carries useful context:
| Call | Returns |
|---|---|
$request->path() | The URI path, e.g. user/profile |
$request->fullUrl() | Full URL including query string |
$request->method() | The HTTP verb, e.g. POST |
$request->ip() | The client IP address |
$request->query('page', 1) | A query-string param with a default |
$request->expectsJson() | Whether to respond with JSON (API vs. browser) |
Handling File Uploads
File uploads need one extra thing on the form: enctype="multipart/form-data". Without it, the browser sends only the filename, not the bytes.
<form action="{{ route('profile.update') }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<label for="avatar">Profile Picture</label>
<input type="file" id="avatar" name="avatar">
<button type="submit">Update Profile</button>
</form>
On the server, the uploaded file is a Illuminate\Http\UploadedFile instance. Validate it, inspect it, then store it:
public function update(Request $request)
{
$request->validate([
'avatar' => 'required|image|max:2048', // image, max 2 MB (2048 KB)
]);
if ($request->hasFile('avatar')) {
$file = $request->file('avatar');
// Inspect properties
$extension = $file->extension(); // guessed from content
$size = $file->getSize(); // bytes
// Store on the "public" disk under storage/app/public/avatars,
// letting Laravel generate a unique, unguessable filename
$path = $file->store('avatars', 'public');
// Or store with a deterministic name
$path = $file->storeAs('avatars', 'user_' . $request->user()->id . '.' . $extension, 'public');
$request->user()->update(['avatar' => $path]);
}
return back()->with('success', 'Profile updated!');
}
π‘ Serving stored files
Files on the public disk live in storage/app/public, which isn't web-accessible by default. Run php artisan storage:link once to create a symlink from public/storage, then reach a file with asset('storage/' . $path).
Worked Example: Create a Post
Let's tie everything together into a real "create a blog post" flow β route, controller, and Blade view with error display, old input, a select, and a file upload.
Routes
<?php
// routes/web.php
use App\Http\Controllers\PostController;
Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
Controller
<?php
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PostController extends Controller
{
public function create()
{
return view('posts.create', [
'categories' => Category::orderBy('name')->get(),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'content' => 'required|string|min:50',
'image' => 'nullable|image|max:2048',
]);
// Derive a slug; attach the author
$validated['slug'] = Str::slug($validated['title']);
$validated['user_id'] = $request->user()->id;
$post = Post::create($validated);
if ($request->hasFile('image')) {
$path = $request->file('image')->store('posts', 'public');
$post->update(['image' => $path]);
}
return redirect()
->route('posts.show', $post)
->with('success', 'Post created successfully!');
}
}
View
{{-- resources/views/posts/create.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="container">
<h1>Create New Post</h1>
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title"
value="{{ old('title') }}"
class="@error('title') is-invalid @enderror">
@error('title') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group">
<label for="category_id">Category</label>
<select id="category_id" name="category_id">
<option value="">Select a category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}"
@selected(old('category_id') == $category->id)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group">
<label for="content">Content</label>
<textarea id="content" name="content" rows="10">{{ old('content') }}</textarea>
@error('content') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="form-group">
<label for="image">Featured Image</label>
<input type="file" id="image" name="image">
@error('image') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<button type="submit">Create Post</button>
</form>
</div>
@endsection
Note the modern Blade touches: @error('field') for per-field messages and @selected(...) to preselect the previously chosen option. Both keep the template terse and readable.
What the user sees on a validation failure
Title field is required.
The selected category is invalid.
Content must be at least 50 characters.
β¦and every field they did fill stays filled, thanks to old().
Hands-on Exercise
ποΈ Build a Contact Form
Objective: Build a working contact form from scratch, covering the full round-trip.
Instructions:
- Add two routes:
GET /contact(namedcontact.show) andPOST /contact(namedcontact.submit). - Create a Blade view with
name,email,subject, andmessagefields β remember@csrfandold(). - In the controller's
submitmethod, validate all four fields, then redirect back with asuccessflash message. - Display validation errors at the top of the form and re-fill every field on failure.
- Stretch: add an optional
attachmentfile input (PDF, max 5 MB) and store it on thepublicdisk.
π‘ Hint
For the attachment, the rule is 'attachment' => 'nullable|file|mimes:pdf|max:5120' (5120 KB = 5 MB), and don't forget enctype="multipart/form-data" on the <form> tag or the file never arrives.
β Solution outline
// routes/web.php
Route::get('/contact', [ContactController::class, 'show'])->name('contact.show');
Route::post('/contact', [ContactController::class, 'submit'])->name('contact.submit');
// ContactController@submit
public function submit(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email',
'subject' => 'required|string|max:150',
'message' => 'required|string|min:10',
'attachment' => 'nullable|file|mimes:pdf|max:5120',
]);
if ($request->hasFile('attachment')) {
$validated['attachment'] = $request->file('attachment')->store('contact', 'public');
}
// ...store or email $validated...
return redirect()->route('contact.show')
->with('success', 'Thanks β we\'ll be in touch!');
}
Best Practices
β Do
- Always add
@csrfto every POST/PUT/PATCH/DELETE form. - Target named routes (
route('posts.store')) instead of hardcoded URLs. - Repopulate with
old()so users never lose their typing. - Validate before doing anything β pass the validated array to the model, not raw input.
- Redirect after a successful POST (Post/Redirect/Get) so refreshes don't resubmit.
β οΈ Don't
- Don't mass-assign
$request->all()β it's a mass-assignment hole. - Don't rely on client-side validation alone β HTML5
requiredis convenience, not security. - Don't forget
enctypeon file-upload forms. - Don't trust filenames or MIME types from the client β validate with
image/mimesrules that inspect the actual file.
Summary & Quiz
π Key Takeaways
- A form is a round-trip: display, submit, validate, then redirect on both success and failure.
@csrfis mandatory;@methodspoofs PUT/PATCH/DELETE from an HTML form.- The
Requestobject exposes input viainput,only,filled, and more β but pass validated data to your models. - File uploads need
enctype="multipart/form-data"and are stored withstore()/storeAs(). old()plus@errorkeep failed forms friendly instead of frustrating.
π― Quick Quiz
Question 1: A POST form submits but Laravel returns a 419 Page Expired error. What is the most likely cause?
Question 2: Which is the safest way to create a model from a form submission?
Question 3: Why does a file input never arrive on the server even though the user picked a file?
π Further Reading
π What's Next?
You've built and processed forms, and you've seen validate() in action. Next we'll go deep on validation itself β the full rule catalog, conditional rules, and how to write your own custom validators.
π Great work!
Your forms are now secure and resilient. Let's make the data flowing through them bulletproof.