TeachyLeaf library

What are you looking for?

Search practical tutorials and browser-based tools.

Laravel · PHP · Beginner

Laravel form validation: CSRF, rules, error bags, and honeypots

Trace TeachyLeaf’s contact form from a browser POST through middleware, validation, feedback, and email handling.

Who this is for: PHP developers who know basic routes, HTTP requests, and Blade templates and want to understand a server-rendered form.

What you will learn: how a web route gets CSRF protection, how $request->validate() returns errors and old input, how Blade displays those errors, and where a honeypot, rate limit, and mail failure fit into the controller.

Prerequisites: Basic PHP syntax, HTTP methods, and Laravel routes. This lesson uses inline request validation; a dedicated Laravel FormRequest class is a separate option for larger applications.

Tested environment: Laravel Framework 13.16.1 with PHP 8.4.15 in the local test environment. The examples trace this site's contact form, routes, controller, and feature tests. Automated mail tests use a fake transport; they do not prove live email delivery.

Follow the contact form request

When a visitor fills out TeachyLeaf’s contact form and clicks submit, the application executes this security and validation sequence:

  1. The browser sends POST /contact with the form fields and a session-associated CSRF token.
  2. The route uses Laravel's web middleware group, which checks the CSRF token; the route also applies throttle:5,10.
  3. ContactController::store checks the optional honeypot field before validation or mail handling.
  4. $request->validate([...]) either returns validated fields or stops the controller and redirects an HTML request back with errors and flashed input.
  5. The contact Blade view uses $errors, @error, and old() to show feedback and restore the visitor's entries.
  6. Valid data is passed to the contact mailable. A transport exception produces an error message; otherwise the controller flashes a success message.

This describes the current code path, not a complete spam-prevention system. A honeypot can miss bots, a CSRF token does not prevent cross-site scripting, and a mail transport accepting a message does not guarantee delivery to the recipient's inbox.

1. CSRF defense with the @csrf directive

Cross-Site Request Forgery (CSRF) is an attack in which a visitor's browser is tricked into sending an unwanted request to a site where that visitor is signed in. For state-changing forms handled by Laravel's web middleware, include a CSRF token:

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

Blade's @csrf directive renders a hidden input containing the current session's token. The value is not a secret encrypted form field; Laravel compares it with the session token when the request is checked:

<input type="hidden" name="_token" value="[current session token]">

If the token is missing or does not match, Laravel rejects the request before the controller runs (commonly with HTTP 419). CSRF tokens do not replace output escaping or protection against cross-site scripting.

2. Catching bot spam with an invisible honeypot

Traditional CAPTCHAs can create friction for legitimate users. One low-friction option is the honeypot technique, with the limitations described below.

In resources/views/frontend/contact.blade.php, we define an extra input field with CSS styling that hides it from real humans:

<div class="contact-form__honeypot" aria-hidden="true">
    <label for="website">Leave this field empty</label>
    <input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>

Some basic bots fill every input they find, including website. The field is visually hidden and marked aria-hidden in this form, but a honeypot is only a low-friction filter: sophisticated bots can skip it, and it should not be the only safeguard.

In ContactController::store, the field is checked before validation or mail operations:

public function store(Request $request)
{
    $successMessage = 'Thanks! Your message has been sent. We will respond as soon as possible.';

    if ($request->filled('website')) {
        // Discard the submission without sending mail.
        return redirect()->route('contact')->with('status', $successMessage);
    }
    ...
}

The response deliberately matches the normal success response, so the form does not reveal whether the honeypot was triggered. This does not guarantee that the bot will stop trying.

3. Validating input and preventing email header injection

Input validation must always happen on the server. Client-side HTML5 attributes like required can be easily bypassed using developer tools or curl.

In ContactController, we define explicit rules for every allowed field:

$validated = $request->validate([
    'name'    => ['required', 'string', 'min:2', 'max:120', 'not_regex:/[\r\n]/'],
    'email'   => ['required', 'email:rfc', 'max:254'],
    'subject' => ['required', 'string', 'min:3', 'max:160', 'not_regex:/[\r\n]/'],
    'message' => ['required', 'string', 'min:10', 'max:2000'],
]);

The not_regex:/[\r\n]/ rule rejects line breaks in the single-line name and subject values. This is a useful safeguard when such values may appear in mail headers; it is not a substitute for context-appropriate validation and safe use of the mail API.

The controller passes only the validated fields to ContactMessage. The mailable sets the visitor's email and name as Reply-To, while the message body is rendered through a Blade email view.

4. Restoring input and displaying errors with Blade

When validation fails, Laravel halts execution and throws a ValidationException. The framework catches this exception and performs two automated actions:

  1. For an HTML request, redirects back and flashes input to the session (excluding fields Laravel is configured not to flash, such as passwords).
  2. Flashes the validation messages to the session. Blade exposes the default error bag as $errors.

In your Blade view, display inline field errors using the @error directive, and restore what the user typed using the old() helper:

<div>
    <label for="name">Your name</label>
    <input type="text" id="name" name="name" value="{{ old('name') }}" required>
    
    @error('name')
        <div class="form-error" role="alert">{{ $message }}</div>
    @enderror
</div>

Blade's escaped {{ ... }} output keeps restored text from being interpreted as HTML. Each field can show its own error while valid entries remain available for correction. A request that expects JSON receives a validation error response instead of the browser redirect.

When to use a named error bag

This contact page uses the default error bag, which is enough for its single form. If a page has multiple forms, named bags can keep their errors separate:

$request->validateWithBag('contact', [
    'email' => ['required', 'email'],
]);
@error('email', 'contact')
    <div role="alert">{{ $message }}</div>
@enderror

Use the same bag name in the controller and view. Do not switch a single-form page to a named bag unless its Blade error directives are updated too.

5. Handling the valid request and mail failure

After validation, TeachyLeaf's controller creates and sends a ContactMessage. If the configured mail transport throws a transport exception, it logs the failure and redirects back with a user-facing fallback and the entered fields (excluding the CSRF and honeypot fields). A success flash is added only after the send call completes.

The feature tests use Mail::fake() to verify that a ContactMessage is sent for valid data and that honeypot submissions send no mail. The mailable sets the visitor's supplied name and email as Reply-To. A fake does not test SMTP credentials, inbox delivery, or production configuration; those require the separate live check in Day 12 of the roadmap.

6. Rate limiting with route middleware

Validation prevents invalid data from reaching your database, but a script could still flood your server with hundreds of requests per minute. In routes/web.php, we protect the route using Laravel’s built-in throttle middleware:

Route::post('/contact', [ContactController::class, 'store'])
    ->middleware('throttle:5,10')
    ->name('contact.store');

In Laravel's throttle middleware, throttle:5,10 sets a limit of five attempts in a ten-minute decay window. The application uses this as an additional guard; proxy and IP configuration, the cache store, and the traffic pattern affect how a production limit behaves. When the limit is exceeded, Laravel responds with HTTP 429 Too Many Requests.

7. Automated testing for validation rules

Never rely solely on manual testing for form validation. In TeachyLeaf’s test suite (tests/Feature/ContactPageTest.php), automated tests verify our rules in milliseconds:

public function test_contact_form_rejects_invalid_input(): void
{
    $response = $this->from('/contact')->post(route('contact.store'), [
        'name'    => 'A',              // Too short (min:2)
        'email'   => 'not-an-email',   // Invalid format
        'subject' => 'X',              // Too short (min:3)
        'message' => 'short',          // Too short (min:10)
    ]);

    $response->assertSessionHasErrors(['name', 'email', 'subject', 'message']);
}

Similarly, we test that the honeypot intercepts automated bots without dispatching mail:

            public function test_contact_form_honeypot_discards_bot_submissions_without_sending_mail(): void
{
    Mail::fake();

    $response = $this->from('/contact')->post(route('contact.store'), [
        'name'    => 'Amit Kumar Das',
        'email'   => 'amit@example.com',
        'subject' => 'Spam subject',
        'message' => 'This is an automated spam message.',
        'website' => 'https://spam.example', // Bot filled the honeypot
    ]);

    $response->assertRedirect(route('contact'));
    Mail::assertNothingOutgoing();
}

These feature tests exercise the application response and mail dispatch boundary. Laravel's test environment does not by itself prove that the browser's CSRF token is enforced in production or that a real mail server accepts and delivers a message; verify those deployment behaviors separately.

Summary of best practices

  • Include @csrf in state-changing forms that use Laravel's web middleware.
  • Treat a honeypot as one low-friction signal, not a complete spam defense.
  • Never trust client-side validation alone; validate every field on the server with appropriate type, length, and format rules.
  • Reject line breaks in single-line values that may be used in mail headers, and use the framework mail API safely.
  • Use old() and @error to give users immediate, helpful feedback without wiping out their input.
  • Protect sensitive public endpoints with throttle rate limiting.

References

Continue learning

First trace routing and middleware in the Laravel request lifecycle lesson, then inspect the real contact form and its tested-example approach.