Stop form spam in Laravel

Updated

This guide adds Bot403 to a contact form in Laravel. The browser solves a small proof of work. Then a controller checks the token with one call, before it accepts the message. The visitor sees no puzzle, and the script sets no cookie.

The example uses Laravel 13.38 on PHP 8.5, in the official composer Docker image. It was run on 23 October 2026 against a local copy of the verify API. In a browser, the tests covered a good token, a filled honeypot, a capped month and an API that does not answer. The two feature tests in the "Test it" section were also run.

Before you start

Sign in. Create a site. Copy its public key (pk_) and its secret key (sk_). Put the secret key in .env. Then read it in config/services.php:

.env
# .env
BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
config/services.php
// config/services.php
'bot403' => [
    'secret' => env('BOT403_SECRET_KEY'),
],

In the controller, read the key with config(), never with env(). After php artisan config:cache, env() returns null outside the config files.

1. The form

The script tag protects every form on the page. Before the browser sends the form, the script adds a hidden _bot403 field with the token.

resources/views/contact.blade.php
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contact</title>
  <script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async></script>
</head>
<body>
  <form method="POST" action="/contact">
    @csrf
    <label>Email <input name="email" type="email" value="{{ old('email') }}" required></label>
    <label>Message <textarea name="message" required>{{ old('message') }}</textarea></label>
    <button type="submit">Send</button>
    @error('form') <p role="alert">{{ $message }}</p> @enderror
  </form>
</body>
</html>

2. The routes

routes/web.php
<?php

use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;

Route::view('/contact', 'contact');
Route::post('/contact', [ContactController::class, 'store']);
Route::view('/thanks', 'thanks');

3. The controller

The controller sends the form fields to Bot403. It accepts the message only when the answer is ok: true. If the verify API refuses the token, the controller sends the visitor back to the form with the old input and an error.

app/Http/Controllers/ContactController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ContactController extends Controller
{
    public function store(Request $request)
    {
        // 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field.
        //    The CSRF token (_token) stays on your server.
        try {
            $result = Http::asForm()
                ->withToken(config('services.bot403.secret'))
                ->timeout(5)
                ->post('https://bot403.com/api/v1/verify', $request->except('_token'))
                ->json();
        } catch (ConnectionException $e) {
            Log::warning('bot403 verify failed, letting the form through', ['error' => $e->getMessage()]);
            $result = ['ok' => true];
        }
        $result ??= ['ok' => true]; // not JSON: Bot403 is down, let the form through

        // 2. Refuse when ok is false, except for reason "quota"
        if (! $result['ok'] && ($result['reason'] ?? '') !== 'quota') {
            return back()->withErrors(['form' => 'Try again'])->withInput();
        }

        // A real visitor: send the email or store the message here
        return redirect('/thanks');
    }
}

If Bot403 does not answer in 5 seconds, the controller accepts the form and logs a warning.

The trap: the CSRF token

If a POST to a web route has no CSRF token, Laravel refuses it with HTTP 419. Bot403 does not replace this check, because the two checks stop different things. CSRF stops other sites that send forms in the name of your visitor. Bot403 stops scripts that send forms automatically. Keep @csrf in the form. Do not add the route to the except list of preventRequestForgery() to make spam tests pass.

The @csrf field arrives in the request as _token. The controller sends $request->except('_token'). As a result, the CSRF token stays on your server. Bot403 reads only its own field and the honeypot field.

The 419 also explains why a quick test with curl fails before your code runs. The Laravel test runner skips the CSRF check. For this reason, test the route with a feature test.

Test it

Http::fake() replaces the verify API. As a result, the test needs no network and no key:

tests/Feature/ContactTest.php
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class ContactTest extends TestCase
{
    public function test_a_verified_form_goes_through(): void
    {
        Http::fake(['bot403.com/*' => Http::response(['ok' => true, 'site' => 'pk_live_test'])]);

        $this->post('/contact', ['email' => '[email protected]', 'message' => 'Hi', '_bot403' => 'token'])
            ->assertRedirect('/thanks');

        Http::assertSent(fn ($request) => $request['_bot403'] === 'token' && ! isset($request['_token']));
    }

    public function test_a_failed_token_is_refused(): void
    {
        Http::fake(['bot403.com/*' => Http::response(['ok' => false, 'site' => 'pk_live_test', 'reason' => 'invalid'])]);

        $this->post('/contact', ['email' => '[email protected]', 'message' => 'Hi'])
            ->assertSessionHasErrors('form');
    }
}
Terminal
php artisan test --filter=ContactTest
#   PASS  Tests\Feature\ContactTest
#   ✓ a verified form goes through
#   ✓ a failed token is refused

Then open /contact in a browser. Send the form. The browser goes to /thanks, and the site in your dashboard shows one check. If you get a failure that you do not expect, read the troubleshooting page. It lists every reason and its fix.