Introduction to Form Handling
Forms are the primary way users interact with web applications, making form handling a critical part of web development. Laravel provides a comprehensive, elegant system for processing forms that balances security, developer experience, and user-friendly feedback.
Think of form handling like processing mail at a post office. When an envelope (form data) arrives, you need to:
- Check if it meets requirements (validation)
- Verify the sender (CSRF protection)
- Extract the contents (request data retrieval)
- Process the information (business logic)
- Send a response (redirect or display)
Laravel streamlines all these steps, making it both easier to implement and more secure by default.
Creating Forms in Laravel
Let's start by looking at how to create forms in Laravel using Blade templates:
Basic Form Structure
<form action="{{ route('products.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="name">Product Name</label>
<input type="text" name="name" id="name" value="{{ old('name') }}"
class="form-control @error('name') is-invalid @enderror">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="price">Price</label>
<input type="number" name="price" id="price" value="{{ old('price') }}"
class="form-control @error('price') is-invalid @enderror" step="0.01">
@error('price')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="category_id">Category</label>
<select name="category_id" id="category_id" class="form-control @error('category_id') is-invalid @enderror">
<option value="">Select Category</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea name="description" id="description" class="form-control @error('description') is-invalid @enderror"
rows="5">{{ old('description') }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="image">Product Image</label>
<input type="file" name="image" id="image" class="form-control-file @error('image') is-invalid @enderror">
@error('image')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<button type="submit" class="btn btn-primary">Create Product</button>
</form>
Key elements in a Laravel form include:
- @csrf directive - Adds a hidden field with a CSRF token to prevent cross-site request forgery attacks
- old() helper - Retrieves old input if the form was submitted but validation failed
- @error directive - Checks for validation errors for a specific field
- route() helper - Generates the correct URL for the form action based on named routes
This approach follows the "Post/Redirect/Get" pattern, which prevents duplicate form submissions and provides a better user experience by maintaining form state across validation failures.
Method Spoofing for PUT, PATCH, DELETE
HTML forms only support GET and POST methods, but RESTful applications often need to use PUT, PATCH, or DELETE for updates and deletions. Laravel provides a simple way to "spoof" these methods:
Method Spoofing Example
<form action="{{ route('products.update', $product) }}" method="POST">
@csrf
@method('PUT')
<!-- Form fields -->
<button type="submit">Update Product</button>
</form>
<form action="{{ route('products.destroy', $product) }}" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete Product</button>
</form>
The @method directive adds a hidden _method field to the form that Laravel recognizes and uses to override the HTTP method.
This is like putting special handling instructions inside a regular envelope - the postal service (browser) still delivers it as normal mail (POST request), but the recipient (Laravel) sees the instructions and processes it accordingly.
Accessing Form Data
When a form is submitted, Laravel makes the form data available through the Request object, which provides several methods for accessing and manipulating the input:
Basic Request Handling
public function store(Request $request)
{
// Get all input data as an array
$allData = $request->all();
// Get a specific input value
$name = $request->input('name');
// Alternative shorter syntax
$name = $request->name;
// Get input with a default value if not present
$sortBy = $request->input('sort', 'created_at');
// Check if input exists
if ($request->has('name')) {
// Process the name
}
// Check if input exists and is not empty
if ($request->filled('name')) {
// Process non-empty name
}
// Retrieve only certain fields
$credentials = $request->only(['email', 'password']);
// Retrieve all except certain fields
$productData = $request->except(['_token', '_method']);
// Retrieve from nested input
$street = $request->input('address.street');
// Retrieve arrays
$selectedTags = $request->input('tags', []);
// Retrieve file uploads
if ($request->hasFile('image')) {
$image = $request->file('image');
// Check if upload was successful
if ($image->isValid()) {
// Get original filename
$filename = $image->getClientOriginalName();
// Get file extension
$extension = $image->getClientOriginalExtension();
// Store the file
$path = $image->store('products', 'public');
// Or with custom filename
$path = $image->storeAs('products', 'custom_name.jpg', 'public');
}
}
}
The Request object is like having a personal assistant who organizes all the paperwork (form data) you receive, making it easy to find exactly what you need without wading through everything manually.
Validation Fundamentals
Data validation is critical for any application that accepts user input. Laravel provides a powerful, flexible validation system to ensure that incoming data meets your requirements before you process it.
Basic Validation in Controller
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',
'website' => 'nullable|url',
'terms' => 'accepted',
]);
// If validation fails, the user is automatically redirected back
// with errors in the session
// If validation passes, the validated data is returned
User::create($validated);
return redirect()->route('dashboard')->with('success', 'Account created!');
}
With Custom Error Messages
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|confirmed',
], [
'name.required' => 'We need to know your name!',
'email.unique' => 'This email address is already registered.',
'password.confirmed' => 'The passwords do not match.',
'password.min' => 'Your password must be at least 8 characters.',
]);
Think of validation as the bouncer at an exclusive club - it checks if each piece of data meets the entry requirements before allowing it into your application.
Manual Validation
If you need more control over the validation process, you can perform manual validation:
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
]);
if ($validator->fails()) {
// Handle validation failure
return redirect()->back()
->withErrors($validator)
->withInput();
}
// Validation passed
$validated = $validator->validated();
// Process the data
// ...
return redirect()->route('success');
}
Common Validation Rules
Laravel provides a wide range of validation rules to handle common scenarios:
String and Text Validation
'name' => 'required|string|min:2|max:255',
'bio' => 'nullable|string|max:1000',
'username' => 'required|alpha_dash|unique:users,username',
'title' => 'required|string|max:255|not_regex:/bad|word/i',
'excerpt' => 'required|string|max:255',
'slug' => 'required|string|max:100|regex:/^[a-z0-9-]+$/',
'content' => 'required|string',
'options' => 'json',
Numeric Validation
'age' => 'required|integer|min:18|max:120',
'price' => 'required|numeric|min:0|max:999999.99',
'discount' => 'nullable|numeric|between:0,100',
'quantity' => 'required|integer|min:1',
'rating' => 'nullable|integer|in:1,2,3,4,5',
'priority' => 'required|integer|gt:0',
'sequence' => 'required|integer|gte:previous_sequence',
Date and Time Validation
'birth_date' => 'required|date|before:today',
'appointment' => 'required|date|after:tomorrow',
'start_date' => 'required|date',
'end_date' => 'required|date|after:start_date',
'published_at' => 'nullable|date_format:Y-m-d H:i:s',
'anniversary' => 'nullable|date_format:m/d/Y',
'expires_at' => 'required|date|after:'+2 days'',
File Upload Validation
'avatar' => 'nullable|image|max:1024',
'document' => 'required|file|mimes:pdf,doc,docx|max:10240',
'photo' => 'required|image|dimensions:min_width=800,min_height=600',
'gallery.*' => 'image|max:2048',
'video' => 'nullable|file|mimetypes:video/mp4,video/quicktime|max:51200',
'csv' => 'required|file|mimes:csv,txt|max:2048',
Array and Object Validation
'tags' => 'array|min:1|max:5',
'tags.*' => 'integer|exists:tags,id',
'roles' => 'required|array',
'roles.*' => 'exists:roles,id',
'metadata' => 'array',
'metadata.color' => 'required|string|in:red,blue,green',
'metadata.size' => 'required|string|in:small,medium,large',
'addresses' => 'required|array|min:1',
'addresses.*.street' => 'required|string|max:255',
'addresses.*.city' => 'required|string|max:100',
'addresses.*.zip' => 'required|string|max:20',
Special Validation Rules
'email' => 'required|email:rfc,dns',
'password' => 'required|min:8|regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/',
'current_password' => 'required|current_password',
'new_password' => 'required|min:8|confirmed|different:current_password',
'terms' => 'accepted',
'recaptcha' => 'required|recaptcha',
'website' => 'nullable|url',
'phone' => 'required|regex:/^([0-9\s\-\+\(\)]*)$/|min:10',
'ip_address' => 'nullable|ip',
'uuid' => 'required|uuid',
These validation rules cover a wide range of use cases, from simple required fields to complex nested array validation. Laravel's rule system is like having a comprehensive checklist for data quality - it ensures that each piece of information meets specific criteria before it enters your application.
Custom Validation Rules
When the built-in validation rules aren't enough, Laravel offers several ways to create custom validation rules:
Closure-Based Rules
$validator = Validator::make($request->all(), [
'password' => [
'required',
'min:8',
function ($attribute, $value, $fail) {
if (strtolower($value) == 'password') {
$fail('The ' . $attribute . ' cannot be "password".');
}
},
],
]);
Rule Object
// Generate a custom rule
php artisan make:rule StrongPassword
// In app/Rules/StrongPassword.php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class StrongPassword implements Rule
{
public function passes($attribute, $value)
{
// Password must contain at least one uppercase letter,
// one lowercase letter, one number, and be at least 8 characters
return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value);
}
public function message()
{
return 'The :attribute must contain at least one uppercase letter,
one lowercase letter, and one number.';
}
}
// Using the rule
$request->validate([
'password' => ['required', new StrongPassword],
]);
Implicit Rule Object
// For a rule that validates without the field being present
// Generate an implicit rule
php artisan make:rule ValidRecaptcha --implicit
// In app/Rules/ValidRecaptcha.php
namespace App\Rules;
use Illuminate\Contracts\Validation\ImplicitRule;
use GuzzleHttp\Client;
class ValidRecaptcha implements ImplicitRule
{
public function passes($attribute, $value)
{
$client = new Client();
$response = $client->post(
'https://www.google.com/recaptcha/api/siteverify',
[
'form_params' => [
'secret' => config('services.recaptcha.secret'),
'response' => $value,
'remoteip' => request()->ip(),
]
]
);
$body = json_decode((string)$response->getBody());
return $body->success;
}
public function message()
{
return 'The reCAPTCHA verification failed.';
}
}
Custom Validation Rules using Validator::extend
// In a service provider
public function boot()
{
Validator::extend('alpha_spaces', function ($attribute, $value, $parameters, $validator) {
return preg_match('/^[\pL\s]+$/u', $value);
}, 'The :attribute may only contain letters and spaces.');
}
// Usage
$request->validate([
'name' => 'required|alpha_spaces|max:255',
]);
Custom validation rules are like creating specialized quality control tools for your specific product requirements. While standard tools work for common materials, sometimes you need purpose-built instruments to ensure quality for your unique needs.
Displaying Validation Errors
When validation fails, Laravel automatically redirects the user back to the previous page and flashes the validation errors to the session. Here's how to display these errors in your Blade templates:
Checking for Errors
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Checking for Specific Field Errors
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" name="email" id="email"
class="form-control @error('email') is-invalid @enderror"
value="{{ old('email') }}">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
Checking First Error Only
{{ $errors->first('email') }}
Checking for Nested Field Errors
@error('addresses.0.street')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
Good error display is like clear communication in a conversation - it helps users understand what went wrong and how to fix it, reducing frustration and improving the overall experience.
Form Request Validation
For complex forms with many validation rules, Laravel provides Form Request validation, which encapsulates validation logic in dedicated classes:
Creating a Form Request
php artisan make:request StoreProductRequest
Form Request Class
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('create', Product::class);
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:products,slug',
'category_id' => 'required|integer|exists:categories,id',
'price' => 'required|numeric|min:0',
'description' => 'required|string',
'image' => 'nullable|image|max:2048',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'active' => 'boolean',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'name.required' => 'A product name is required',
'category_id.exists' => 'The selected category is invalid',
'price.min' => 'Price cannot be negative',
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'category_id' => 'category',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
$this->merge([
'slug' => \Str::slug($this->name),
'active' => $this->active ?? false,
]);
}
/**
* Handle a passed validation attempt.
*/
protected function passedValidation()
{
// This runs after validation passes but before the controller
if ($this->hasFile('image')) {
$this->merge([
'image_path' => $this->file('image')->store('products', 'public'),
]);
}
}
}
Using Form Requests in Controllers
public function store(StoreProductRequest $request)
{
// The request is already validated!
$product = Product::create($request->validated());
if ($request->has('tags')) {
$product->tags()->sync($request->tags);
}
return redirect()->route('products.show', $product)
->with('success', 'Product created successfully!');
}
Form Requests are like having a dedicated quality control department for each product line - they provide specialized validation tailored to the specific requirements of each form, keeping your controllers clean and focused on business logic.
Advantages of Form Requests
- Separation of concerns: Moves validation logic out of controllers
-
Authorization and validation in one place: The
authorize()method lets you determine if the user has permission to perform the action - Reusability: The same validation rules can be used across multiple controllers
-
Data preparation:
prepareForValidation()lets you modify the data before validation -
After-validation processing:
passedValidation()lets you modify the validated data before it reaches the controller
Ajax Form Handling
Modern web applications often use Ajax for form submissions to provide a smoother user experience. Laravel makes it easy to handle Ajax form submissions:
Controller for Ajax Requests
public function store(Request $request)
{
try {
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => bcrypt($validated['password']),
]);
return response()->json([
'success' => true,
'message' => 'User created successfully!',
'user' => $user,
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'An error occurred while creating the user.',
], 500);
}
}
JavaScript for Ajax Form Submission
// Using jQuery (make sure to include jQuery in your project)
$(document).ready(function() {
$('#registerForm').on('submit', function(e) {
e.preventDefault();
// Clear previous errors
$('.invalid-feedback').remove();
$('.is-invalid').removeClass('is-invalid');
$.ajax({
url: $(this).attr('action'),
type: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
success: function(response) {
if (response.success) {
// Show success message
$('#statusMessage').html(
'<div class="alert alert-success">' +
response.message +
'</div>'
);
// Redirect or perform other actions
setTimeout(function() {
window.location.href = '/dashboard';
}, 1500);
}
},
error: function(xhr) {
if (xhr.status === 422) {
var errors = xhr.responseJSON.errors;
// Display each error under its field
$.each(errors, function(field, messages) {
var input = $('#' + field);
input.addClass('is-invalid');
$.each(messages, function(i, message) {
input.after(
'<div class="invalid-feedback">' +
message +
'</div>'
);
});
});
// Scroll to first error
if ($('.is-invalid').length) {
$('html, body').animate({
scrollTop: $('.is-invalid').first().offset().top - 100
}, 500);
}
} else {
// Handle other errors
$('#statusMessage').html(
'<div class="alert alert-danger">' +
'An error occurred. Please try again later.' +
'</div>'
);
}
}
});
});
});
Using Fetch API (Modern JavaScript)
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('registerForm');
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Clear previous errors
document.querySelectorAll('.invalid-feedback').forEach(el => el.remove());
document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
// Note: Don't set Content-Type with FormData
}
});
const result = await response.json();
if (!response.ok) {
throw { status: response.status, errors: result.errors };
}
// Success
document.getElementById('statusMessage').innerHTML =
`<div class="alert alert-success">${result.message}</div>`;
// Redirect after delay
setTimeout(() => {
window.location.href = '/dashboard';
}, 1500);
} catch (error) {
if (error.status === 422 && error.errors) {
// Validation errors
Object.entries(error.errors).forEach(([field, messages]) => {
const input = document.getElementById(field);
input.classList.add('is-invalid');
messages.forEach(message => {
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = message;
input.parentNode.insertBefore(feedback, input.nextSibling);
});
});
// Scroll to first error
const firstError = document.querySelector('.is-invalid');
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
// Other errors
document.getElementById('statusMessage').innerHTML =
'<div class="alert alert-danger">An error occurred. Please try again later.</div>';
}
}
});
});
Ajax form handling is like having a messenger service that delivers your mail without you having to leave your home. Instead of traveling to a new page for each form submission, the data is sent in the background, providing a smoother, more responsive user experience.
File Uploads
Handling file uploads is a common requirement in web applications. Laravel provides a clean API for working with uploaded files:
File Upload Form
<form action="{{ route('products.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<!-- Other form fields -->
<div class="form-group">
<label for="image">Product Image</label>
<input type="file" name="image" id="image"
class="form-control-file @error('image') is-invalid @enderror">
@error('image')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="gallery">Product Gallery (Multiple Images)</label>
<input type="file" name="gallery[]" id="gallery" multiple
class="form-control-file @error('gallery.*') is-invalid @enderror">
@error('gallery.*')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<button type="submit" class="btn btn-primary">Create Product</button>
</form>
Handling File Uploads in Controller
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0',
'image' => 'required|image|max:2048', // 2MB max
'gallery.*' => 'image|max:2048',
]);
$product = new Product;
$product->name = $request->name;
$product->price = $request->price;
// Handle single file upload
if ($request->hasFile('image')) {
$image = $request->file('image');
// Check if upload is valid
if ($image->isValid()) {
// Store file in 'public/products' directory
$path = $image->store('products', 'public');
$product->image_path = $path;
// Or store with custom filename
$filename = time() . '_' . $image->getClientOriginalName();
$path = $image->storeAs('products', $filename, 'public');
// Get image details if needed
$extension = $image->getClientOriginalExtension();
$size = $image->getSize();
$mimeType = $image->getMimeType();
}
}
$product->save();
// Handle multiple file uploads
if ($request->hasFile('gallery')) {
foreach ($request->file('gallery') as $image) {
if ($image->isValid()) {
$path = $image->store('gallery', 'public');
// Create gallery item
$product->gallery()->create([
'image_path' => $path
]);
}
}
}
return redirect()->route('products.show', $product)
->with('success', 'Product created successfully!');
}
Advanced: Image Manipulation with Intervention Image
// First install Intervention Image
// composer require intervention/image
// In controller
use Intervention\Image\Facades\Image;
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'image' => 'required|image|max:2048',
]);
$product = new Product;
$product->name = $request->name;
if ($request->hasFile('image')) {
$image = $request->file('image');
// Generate a unique filename
$filename = time() . '_' . uniqid() . '.' . $image->getClientOriginalExtension();
$path = storage_path('app/public/products/' . $filename);
// Ensure directory exists
if (!file_exists(storage_path('app/public/products'))) {
mkdir(storage_path('app/public/products'), 0755, true);
}
// Create thumbnail
$thumbnail = Image::make($image->getRealPath())
->resize(200, 200, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})
->encode($image->getClientOriginalExtension(), 80);
$thumbnailPath = storage_path('app/public/products/thumbnails');
if (!file_exists($thumbnailPath)) {
mkdir($thumbnailPath, 0755, true);
}
$thumbnailFilename = 'thumb_' . $filename;
$thumbnail->save($thumbnailPath . '/' . $thumbnailFilename);
// Resize and save main image
$mainImage = Image::make($image->getRealPath())
->resize(800, null, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})
->encode($image->getClientOriginalExtension(), 90);
$mainImage->save($path);
// Save paths to database
$product->image_path = 'products/' . $filename;
$product->thumbnail_path = 'products/thumbnails/' . $thumbnailFilename;
}
$product->save();
return redirect()->route('products.show', $product);
}
File upload handling in Laravel is like having a specialized document processing system. It not only receives the documents but can also verify their type, check their size, organize them into appropriate folders, and even process or modify them before storage.
Practical Example: Contact Form with Validation and Email
Let's put these concepts together with a practical example of a contact form that validates input and sends an email:
Contact Form (Blade Template)
<!-- resources/views/contact.blade.php -->
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">Contact Us</div>
<div class="card-body">
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
<form action="{{ route('contact.submit') }}" method="POST">
@csrf
<div class="form-group">
<label for="name">Your Name</label>
<input type="text" name="name" id="name"
class="form-control @error('name') is-invalid @enderror"
value="{{ old('name') }}">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" name="email" id="email"
class="form-control @error('email') is-invalid @enderror"
value="{{ old('email') }}">
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" name="subject" id="subject"
class="form-control @error('subject') is-invalid @enderror"
value="{{ old('subject') }}">
@error('subject')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea name="message" id="message" rows="5"
class="form-control @error('message') is-invalid @enderror">{{ old('message') }}</textarea>
@error('message')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<div class="g-recaptcha" data-sitekey="{{ config('services.recaptcha.site_key') }}"></div>
@error('g-recaptcha-response')
<div class="text-danger">{{ $message }}</div>
@enderror
</div>
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
@endpush
Form Request for Validation
// app/Http/Requests/ContactFormRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ContactFormRequest extends FormRequest
{
public function authorize()
{
return true; // Anyone can submit the contact form
}
public function rules()
{
return [
'name' => 'required|string|max:100',
'email' => 'required|email|max:255',
'subject' => 'required|string|max:150',
'message' => 'required|string|min:20|max:2000',
'g-recaptcha-response' => 'required|recaptcha',
];
}
public function messages()
{
return [
'name.required' => 'Please provide your name.',
'email.email' => 'Please provide a valid email address.',
'message.min' => 'Your message must be at least 20 characters.',
'g-recaptcha-response.required' => 'Please verify that you are not a robot.',
'g-recaptcha-response.recaptcha' => 'The reCAPTCHA verification failed. Please try again.',
];
}
}
Recaptcha Rule (Custom Validation)
// app/Rules/Recaptcha.php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use GuzzleHttp\Client;
class Recaptcha implements Rule
{
public function passes($attribute, $value)
{
$client = new Client();
$response = $client->post(
'https://www.google.com/recaptcha/api/siteverify',
[
'form_params' => [
'secret' => config('services.recaptcha.secret_key'),
'response' => $value,
'remoteip' => request()->ip(),
]
]
);
$body = json_decode((string)$response->getBody());
return $body->success;
}
public function message()
{
return 'The reCAPTCHA verification failed. Please try again.';
}
}
Mailable Class for Email
// Generate a mailable
php artisan make:mail ContactFormMail
// app/Mail/ContactFormMail.php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ContactFormMail extends Mailable
{
use Queueable, SerializesModels;
public $data;
public function __construct($data)
{
$this->data = $data;
}
public function build()
{
return $this->from($this->data['email'], $this->data['name'])
->subject("Contact Form: {$this->data['subject']}")
->markdown('emails.contact-form')
->with([
'name' => $this->data['name'],
'email' => $this->data['email'],
'subject' => $this->data['subject'],
'messageContent' => $this->data['message'],
'ipAddress' => request()->ip(),
'userAgent' => request()->userAgent(),
]);
}
}
Email Template (Blade Markdown)
@component('mail::message')
# New Contact Form Submission
You have received a new message from the contact form.
**Name:** {{ $name }}
**Email:** {{ $email }}
**Subject:** {{ $subject }}
**Message:**
{{ $messageContent }}
---
*This message was sent from IP: {{ $ipAddress }} using {{ $userAgent }}*
@endcomponent
Controller Action
// app/Http/Controllers/ContactController.php
namespace App\Http\Controllers;
use App\Http\Requests\ContactFormRequest;
use App\Mail\ContactFormMail;
use Illuminate\Support\Facades\Mail;
class ContactController extends Controller
{
public function show()
{
return view('contact');
}
public function submit(ContactFormRequest $request)
{
// All validation has already passed at this point
// Prepare data from validated request
$data = $request->validated();
try {
// Send email
Mail::to(config('mail.contact.address'))
->send(new ContactFormMail($data));
// Optional: Log contact submission
\Log::info('Contact form submitted', [
'name' => $data['name'],
'email' => $data['email'],
'subject' => $data['subject'],
]);
// Return success
return redirect()->back()->with('success', 'Thank you for your message! We will get back to you soon.');
} catch (\Exception $e) {
// Log the error
\Log::error('Contact form error', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Return with error
return redirect()->back()
->withInput()
->withErrors(['error' => 'Sorry, there was an error sending your message. Please try again later.']);
}
}
}
Routes Definition
// routes/web.php
Route::get('/contact', [ContactController::class, 'show'])->name('contact');
Route::post('/contact', [ContactController::class, 'submit'])->name('contact.submit');
This contact form example demonstrates many form handling concepts together: input validation with custom rules, form requests, error display, file uploads, email generation, and security features like CSRF protection and reCAPTCHA integration.
Practical Activity: Registration Form with Validation
Let's solidify your understanding with a hands-on activity:
Activity: Create a Complete Registration Form
-
Create a registration form that includes:
- Name, email, password, password confirmation
- Profile image upload
- Date of birth with age validation (18+)
- Address fields (street, city, state, zip)
- Terms and conditions acceptance checkbox
-
Implement comprehensive validation:
- Create a custom validation rule for password strength
- Implement client-side validation with JavaScript
- Create a form request class for server-side validation
-
Handle the submitted data:
- Create the user in the database
- Store the profile image
- Create an address record related to the user
- Send a welcome email with verification link
- Implement proper error handling and success messaging
Extension: Implement the registration form as a multi-step wizard with session-based storage between steps.
Best Practices for Form Handling
Follow these best practices to ensure your forms are secure, user-friendly, and maintainable:
Security
- Always use the @csrf directive in forms to prevent CSRF attacks
- Validate all input on the server side, even if you have client-side validation
- Use mass assignment protection with $fillable or $guarded on your models
- Sanitize user input to prevent XSS attacks
- Consider using CAPTCHA or reCAPTCHA for public forms
- Be cautious with file uploads, validating type, size, and scanning for malware if needed
User Experience
- Provide clear, specific error messages that help users correct their input
- Use old() helper to preserve form values on validation failure
- Highlight fields with errors using visual cues (red borders, icons)
- Consider implementing client-side validation for immediate feedback
- Use progressive enhancement - forms should work without JavaScript
- Show success messages after successful form submission
Code Organization
- Use Form Request classes for complex forms to separate validation logic
- Keep controllers thin - move complex processing to services or actions
- Create reusable Blade components for common form elements
- Consider using packages like Laravel Livewire for complex, dynamic forms
- Write tests for your forms, especially for complex validation rules
Performance
- Use eager loading for related models to avoid N+1 query problems
- Consider using queued jobs for processing intensive operations after form submission
- Be mindful of session size when storing form data between requests
- For multi-step forms, consider storing progress in the database for large datasets
Following these best practices is like implementing a well-designed customer service system - it ensures that users have a smooth, secure experience when interacting with your application, while also maintaining code quality and performance.
Summary and Key Takeaways
- Laravel provides comprehensive tools for form handling, including CSRF protection, validation, and file uploads
- Validation can be performed in controllers or using dedicated Form Request classes
- Laravel offers numerous built-in validation rules, plus the ability to create custom rules
- Error handling is streamlined with automatic redirects and session flashing
- File uploads are handled safely with validation for type, size, and other constraints
- Ajax form handling allows for smoother user experiences with partial page updates
- Form handling best practices focus on security, user experience, code organization, and performance
With these tools and techniques, Laravel makes form handling—one of the most common and critical aspects of web development—both secure and developer-friendly.
Further Resources
- Laravel Validation Documentation
- Laravel HTTP Requests Documentation
- Laracasts: Forms and Validation
- "Laravel: Up & Running" by Matt Stauffer (Chapter on Requests and Responses)
- "Mastering Laravel" by Christopher John Pecoraro (Chapters on Form Handling)