Form Handling and Validation

Securely processing user input in Laravel applications

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:

Laravel streamlines all these steps, making it both easier to implement and more secure by default.

flowchart TD A[User Submits Form] --> B[Laravel Receives Request] B --> C{CSRF Valid?} C -->|No| D[Abort 419] C -->|Yes| E[Extract Input Data] E --> F{Validation Passes?} F -->|No| G[Return with Errors] F -->|Yes| H[Process Data] H --> I[Return Response] G --> A I --> J[Redirect with Success] style C fill:#f9d77e style F fill:#f9d77e

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:

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

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

  1. 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
  2. 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
  3. 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
  4. 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

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