Skip to main content

๐Ÿ“ Form Handling and Validation

Forms are where untrusted user input enters your application, so they are also where security and user experience are won or lost. Laravel gives you CSRF protection, a rich validation engine, and clean error feedback โ€” mostly for free.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Build Blade forms with CSRF protection, the old() helper, and method spoofing
  • Read and filter submitted data through the Request object
  • Validate input in a controller and in a dedicated Form Request class
  • Write custom validation rules and display errors clearly in Blade
  • Handle file uploads safely and follow the Post/Redirect/Get pattern

Estimated Time: 45โ€“60 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a validated registration form with a custom password-strength rule.

In This Lesson

The Form Lifecycle

Every form submission travels the same road: the browser sends a request, Laravel checks it is genuine, extracts and validates the data, does the work, and sends a response. Understanding this flow tells you exactly where each tool fits.

flowchart TD A[User submits form] --> B[Laravel receives request] B --> C{CSRF token valid?} C -->|No| D[Abort 419] C -->|Yes| E[Validate input] E -->|Fails| F[Redirect back with errors & old input] E -->|Passes| G[Process data] F --> A G --> H[Redirect with success message]
๐Ÿ’ก Analogy: Handling a form is like a post office processing mail. You confirm the sender is real (CSRF), check the envelope meets requirements (validation), open the contents (request data), act on the letter (business logic), and send a reply (redirect).

๐Ÿ“– Key Terms

CSRF: Cross-Site Request Forgery โ€” an attack where another site tricks a logged-in user's browser into submitting a request. A per-session token defeats it.

Post/Redirect/Get (PRG): after a successful POST, redirect to a GET page so refreshing doesn't resubmit the form.

Building a Blade Form

A well-built Laravel form uses four Blade tools together: @csrf for security, route() for the action URL, old() to repopulate fields after a failed submit, and @error to show messages inline.

<form action="{{ route('products.store') }}" method="POST">
    @csrf

    <div class="form-group">
        <label for="name">Product Name</label>
        <input type="text" name="name" id="name"
               value="{{ old('name') }}"
               class="@error('name') is-invalid @enderror">
        @error('name')
            <p class="invalid-feedback">{{ $message }}</p>
        @enderror
    </div>

    <div class="form-group">
        <label for="price">Price</label>
        <input type="number" step="0.01" name="price" id="price"
               value="{{ old('price') }}"
               class="@error('price') is-invalid @enderror">
        @error('price')
            <p class="invalid-feedback">{{ $message }}</p>
        @enderror
    </div>

    <button type="submit">Create Product</button>
</form>

Method spoofing for PUT / PATCH / DELETE

HTML forms can only send GET and POST. To hit a RESTful PUT, PATCH, or DELETE route, add the @method directive โ€” Laravel reads the hidden _method field and routes accordingly.

<form action="{{ route('products.update', $product) }}" method="POST">
    @csrf
    @method('PUT')
    <!-- fields -->
    <button type="submit">Update</button>
</form>

<form action="{{ route('products.destroy', $product) }}" method="POST">
    @csrf
    @method('DELETE')
    <button type="submit">Delete</button>
</form>

โš ๏ธ Forget @csrf and you get a 419

Every non-GET form needs @csrf. Omit it and Laravel rejects the request with HTTP 419 Page Expired. This is a feature, not a bug โ€” it is your CSRF shield working.

Accessing Request Data

Laravel injects an Illuminate\Http\Request object into your controller method. It offers precise, expressive ways to read input rather than touching PHP's raw $_POST.

public function store(Request $request)
{
    $all   = $request->all();                    // everything
    $name  = $request->input('name');            // one field
    $name  = $request->name;                     // shorthand
    $sort  = $request->input('sort', 'created_at'); // with a default

    if ($request->filled('email')) { /* present and not empty */ }

    $creds   = $request->only(['email', 'password']);
    $payload = $request->except(['_token', '_method']);
    $street  = $request->input('address.street');   // nested / dot notation
}
๐Ÿ’ก Analogy: The Request object is a personal assistant who has already sorted the incoming paperwork. You ask for exactly the document you need instead of rummaging through the whole pile.

Validation Fundamentals

Never trust input. The simplest way to validate is $request->validate(). If any rule fails, Laravel automatically redirects back with the errors and the old input flashed to the session โ€” you write no branching code for the failure path.

public function store(Request $request)
{
    $validated = $request->validate([
        'name'     => 'required|string|max:255',
        'email'    => 'required|email|unique:users,email',
        'password' => 'required|min:8|confirmed',
        'age'      => 'nullable|integer|min:18',
        'terms'    => 'accepted',
    ]);

    // Only reached when validation passes; $validated is safe to use.
    User::create($validated);

    return redirect()->route('dashboard')
                     ->with('success', 'Account created!');
}

๐Ÿ’ก confirmed is a handy convention

The confirmed rule on password automatically checks that a password_confirmation field matches. Name the second field with the _confirmation suffix and it just works.

Custom messages

$request->validate([
    'email'    => 'required|email|unique:users,email',
    'password' => 'required|min:8|confirmed',
], [
    'email.unique'      => 'That email is already registered.',
    'password.min'      => 'Use at least 8 characters.',
    'password.confirmed'=> 'The passwords do not match.',
]);
๐Ÿ’ก Analogy: Validation is the bouncer at a club: each piece of data must meet the entry rules before it gets inside your application.

Rules & Custom Rules

Laravel ships with dozens of rules covering strings, numbers, dates, files, and arrays. A few representative examples:

CategoryExample rule stringMeaning
Stringrequired|string|max:255Present, text, at most 255 chars
Numberrequired|numeric|min:0Present, numeric, non-negative
Daterequired|date|after:todayA valid date later than today
Databaseexists:categories,idValue must match a row's id
Array itemtags.*|exists:tags,idEach array element must exist
Fileimage|max:2048An image up to 2 MB

A custom rule with a closure

$request->validate([
    'password' => [
        'required', 'min:8',
        function ($attribute, $value, $fail) {
            if (strtolower($value) === 'password') {
                $fail('The '.$attribute.' is too obvious.');
            }
        },
    ],
]);

A reusable rule object

For rules you'll use across the app, generate a rule class with php artisan make:rule StrongPassword. Modern Laravel uses the ValidationRule interface:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class StrongPassword implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // At least one lower, one upper, one digit, min 8 chars.
        if (! preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
            $fail('The :attribute needs upper- and lower-case letters and a number.');
        }
    }
}

// Usage
$request->validate([
    'password' => ['required', new StrongPassword],
]);

Form Request Classes

When validation grows, move it out of the controller into a Form Request. It bundles authorization and rules in one testable class, and it validates automatically the moment you type-hint it in a controller method.

php artisan make:request StoreProductRequest
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;

class StoreProductRequest extends FormRequest
{
    // Return false to reject the request with a 403.
    public function authorize(): bool
    {
        return $this->user()->can('create', \App\Models\Product::class);
    }

    public function rules(): array
    {
        return [
            'name'        => 'required|string|max:255',
            'category_id' => 'required|integer|exists:categories,id',
            'price'       => 'required|numeric|min:0',
            'image'       => 'nullable|image|max:2048',
            'tags'        => 'nullable|array',
            'tags.*'      => 'exists:tags,id',
        ];
    }

    public function messages(): array
    {
        return ['category_id.exists' => 'That category no longer exists.'];
    }

    // Tweak the data before rules run.
    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug($this->name)]);
    }
}
// The controller stays thin โ€” validation already happened.
public function store(StoreProductRequest $request)
{
    $product = Product::create($request->validated());
    $product->tags()->sync($request->input('tags', []));

    return redirect()->route('products.show', $product)
                     ->with('success', 'Product created!');
}

โœ… Why Form Requests

They separate concerns, put authorization and validation side by side, are reusable across controllers, and keep controllers focused on business logic. Reach for them as soon as a form has more than a handful of rules.

File Uploads

A form that uploads files needs enctype="multipart/form-data". Validate the file, then store it โ€” store() generates a safe, unique filename and returns the path to save in your database.

<form action="{{ route('products.store') }}" method="POST"
      enctype="multipart/form-data">
    @csrf
    <input type="file" name="image">
    <button type="submit">Upload</button>
</form>
public function store(Request $request)
{
    $request->validate([
        'image' => 'required|image|max:2048',   // max 2 MB
    ]);

    if ($request->hasFile('image')) {
        // Stored under storage/app/public/products; returns e.g. "products/x7Kd.jpg"
        $path = $request->file('image')->store('products', 'public');

        Product::create([...$request->validated(), 'image_path' => $path]);
    }

    return back()->with('success', 'Uploaded!');
}

โš ๏ธ Validate before you trust a filename

Always validate uploads with rules like image or mimes:pdf,docx and a max: size. Never build a storage path from the user-supplied original filename directly, and expose files through the public disk (run php artisan storage:link once) rather than serving from arbitrary paths.

Worked Example: A Contact Form

This ties the pieces together: a Blade form, a Form Request for validation, a controller that follows Post/Redirect/Get, and a success message.

1 ยท The Blade view

@if (session('success'))
    <div class="alert-success">{{ session('success') }}</div>
@endif

<form action="{{ route('contact.submit') }}" method="POST">
    @csrf
    <label for="name">Your Name</label>
    <input id="name" name="name" value="{{ old('name') }}">
    @error('name') <p class="invalid-feedback">{{ $message }}</p> @enderror

    <label for="email">Email</label>
    <input id="email" type="email" name="email" value="{{ old('email') }}">
    @error('email') <p class="invalid-feedback">{{ $message }}</p> @enderror

    <label for="message">Message</label>
    <textarea id="message" name="message">{{ old('message') }}</textarea>
    @error('message') <p class="invalid-feedback">{{ $message }}</p> @enderror

    <button type="submit">Send Message</button>
</form>

2 ยท The Form Request

class ContactFormRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;   // anyone may use the public contact form
    }

    public function rules(): array
    {
        return [
            'name'    => 'required|string|max:100',
            'email'   => 'required|email|max:255',
            'message' => 'required|string|min:20|max:2000',
        ];
    }
}

3 ยท The controller & routes

class ContactController extends Controller
{
    public function show()
    {
        return view('contact');
    }

    public function submit(ContactFormRequest $request)
    {
        // Validation already passed. Send the mail.
        Mail::to(config('mail.contact_address'))
            ->send(new ContactFormMail($request->validated()));

        // Post/Redirect/Get: redirect so a refresh won't resend.
        return redirect()->route('contact')
                         ->with('success', 'Thanks! We will reply soon.');
    }
}

// routes/web.php
Route::get('/contact',  [ContactController::class, 'show'])->name('contact');
Route::post('/contact', [ContactController::class, 'submit'])->name('contact.submit');

What the user sees after a valid submit

Thanks! We will reply soon.

Hands-on Exercise

๐Ÿ‹๏ธ Build a Validated Registration Form

Objective: Combine a Form Request, a custom rule, and error display.

Instructions:

  1. Create a registration form with name, email, password, password_confirmation, and a terms checkbox.
  2. Generate a RegisterRequest Form Request with rules: name required, email required + unique, password confirmed + your custom StrongPassword rule, terms accepted.
  3. Display inline errors with @error and repopulate every field with old() (except password).
  4. On success, create the user and redirect with a flash message (Post/Redirect/Get).
๐Ÿ’ก Hint

The accepted rule is exactly what a "I agree to the terms" checkbox needs. Never echo old('password') back into the field โ€” it is a security and UX anti-pattern. Type-hint RegisterRequest $request in the controller so validation runs automatically.

โœ… Example solution (rules + controller)
// RegisterRequest::rules()
return [
    'name'     => 'required|string|max:255',
    'email'    => 'required|email|unique:users,email',
    'password' => ['required', 'confirmed', new StrongPassword],
    'terms'    => 'accepted',
];

// Controller
public function store(RegisterRequest $request)
{
    User::create($request->safe()->only(['name', 'email', 'password']));
    return redirect()->route('login')
                     ->with('success', 'Account created โ€” please log in.');
}

Best Practices

โœ… Do

  • Always add @csrf to non-GET forms and validate every input on the server.
  • Use old() to preserve input and @error to show clear, specific messages.
  • Move complex validation into Form Request classes and keep controllers thin.
  • Follow Post/Redirect/Get so a page refresh never resubmits.
  • Validate uploads by type and size before storing them.

โš ๏ธ Don't

  • Don't rely on client-side (JavaScript) validation alone โ€” it is a convenience, not a guard.
  • Don't mass-assign $request->all() into a model; use validated() or only().
  • Don't repopulate password fields with old().
  • Don't trust the uploaded file's original name or MIME claim without validation.

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • @csrf protects every non-GET form; @method spoofs PUT/PATCH/DELETE.
  • $request->validate() redirects back with errors and old input automatically on failure.
  • Form Request classes bundle authorization and validation for reuse and testability.
  • Handle file uploads with multipart/form-data, validation, and store(); always follow Post/Redirect/Get.

๐ŸŽฏ Quick Quiz

Question 1: A POST form without @csrf returns HTTP 419. Why?

Question 2: When $request->validate() fails, what happens by default?

Question 3: What is the main advantage of a Form Request over validating inside the controller?

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You can now capture and validate input securely. Next we'll identify who is submitting it โ€” building login, registration, and access control in the Laravel Authentication System.

๐ŸŽ‰ Great work!

Your forms are now secure, validated, and user-friendly.