Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Antispam Bundle Laravel Package

nucleos/antispam-bundle

Nucleos Antispam Bundle provides configurable spam protection for Symfony applications. It integrates with forms to help block automated submissions, adding lightweight bot-detection checks with minimal configuration and no disruption to normal users. Use it to reduce fake registrations, comments, a...

View on GitHub
Deep Wiki
Context7

Getting Started

This is a Symfony bundle, not a Laravel package. Before you invest time, decide which side of the fence you are on:

  • Symfony app (or a polyglot team with a Symfony service): install it directly.
  • Laravel app: there is no bridge, service provider, or config/antispam.php. You will either port the three built-in checkers (~200 LOC total) into Laravel middleware/validation rules, or reach for a Laravel-native equivalent such as spatie/laravel-honeypot. The value of reading this bundle is the patterns, not the runtime code.

Symfony install (the only first-class path)

composer require nucleos/antispam-bundle

Register the bundle (Symfony ≤5.4 style; Flex does this automatically on 6+/7):

// config/bundles.php
return [
    // ...
    Nucleos\AntiSpamBundle\NucleosAntiSpamBundle::class => ['all' => true],
];

Minimal config:

# config/packages/nucleos_anti_spam.yaml
nucleos_anti_spam:
    checks:
        honeypot:
            field_name: website       # the fake field bots love to fill
        minimum_time: 5               # seconds; form submitted faster = spam
        ip_list:
            block: []
            allow: []

Then in a controller:

use Nucleos\AntiSpamBundle\SpamProtectionInterface;

public function contact(Request $request, SpamProtectionInterface $spamProtection): Response
{
    $form = $this->createForm(ContactType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Signature varies by version — check src/SpamProtection.php
        // (`isSpam($form)` in some releases, `isSpam($request)` in others).
        if ($spamProtection->isSpam($form)) {
            return $this->redirectToRoute('contact_thanks'); // silent drop
        }

        // dispatch mail, persist lead, etc.
    }

    return $this->render('contact/index.html.twig', ['form' => $form]);
}

Where to look first:

  1. src/SpamProtection.php and src/SpamProtectionInterface.php — the public entry point and its exact signature.
  2. src/Checker/ — HoneypotChecker, MinimumTimeChecker, IpListChecker. This is the entire product; read all three in under 10 minutes.
  3. config/services.xml — service IDs and checker tags, which you need to register your own check.
  4. src/DependencyInjection/Configuration.php — the authoritative list of config keys (docs and code drift in low-activity packages).
  5. src/Form/Extension/ and src/Twig/ — how fields get injected and what helper renders them.

First use case: a public contact form. Add the honeypot plus a 3–5 second minimum-time check. That combination kills the majority of drive-by form spam with zero user friction and no third-party service.

Laravel equivalent, minimal

If you are staying in Laravel, this is the same first use case without the bundle:

// app/Http/Middleware/AntiSpam.php
public function handle(Request $request, Closure $next)
{
    if ($request->isMethod('POST') && $this->looksLikeSpam($request)) {
        return response()->noContent(200); // don't tip off the bot
    }

    return $next($request);
}

private function looksLikeSpam(Request $request): bool
{
    if (filled($request->input('website'))) {
        return true;
    }

    $renderedAt = (int) $request->input('_ts');
    if ($renderedAt > 0 && (time() - $renderedAt) < 5) {
        return true;
    }

    return in_array($request->ip(), config('antispam.blocked_ips', []), true);
}

Implementation Patterns

1. Silent acceptance over explicit rejection

The bundle's intended flow is to pretend the submission succeeded (redirect to a thank-you page, return 200, do not persist). Bots retry aggressively against visible 422/403 errors and learn your field names. Keep the user-facing path identical for spam and ham; log the rejection server-side:

if ($spamProtection->isSpam($form)) {
    $this->logger->info('Dropped spam submission', [
        'ip'   => $request->getClientIp(),
        'form' => $form->getName(),
    ]);

    return $this->redirectToRoute('contact_thanks');
}

2. Honeypot rendering — visually hidden, not type="hidden"

Most bots fill every input they see; some skip type="hidden". Render a normal text input that is visually removed:

{# templates/_antispam.html.twig #}
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;">
    <input type="text" name="website" value="" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="_ts" value="{{ 'now'|date('U') }}">

Symfony users get this via the bundle's Twig helper placed inside form_start/form_end:

{{ form_start(form) }}
    {{ form_widget(form) }}
    {{ antispam_protection() }}
{{ form_end(form) }}

3. Minimum-time check — cache-aware timestamp

Timestamp the form render, not the request, and store it in the session for verification if you want to resist timestamp tampering:

// controller action that renders the form
$request->getSession()->set('contact_ts', time());
// on submit
$renderedAt = (int) $request->getSession()->get('contact_ts', 0);
if ($renderedAt && (time() - $renderedAt) < 5) {
    // spam
}
$request->getSession()->remove('contact_ts');

A hidden-input timestamp is fine for low-stakes forms but is trivially spoofed; for anything worth protecting, pair it with the session value and a single-use nonce.

4. IP allow/block lists

The bundle's ip_list check is a static list, useful for known-bad ranges but poor day-to-day (no expiry, no CIDR if you're unlucky). Pattern: keep the bundle check for a small permanent blocklist and route richer scoring to a custom checker backed by a cache or database table with TTLs.

In Laravel:

Cache::remember("spam:ip:{$ip}", 3600, fn () => DB::table('spam_ips')->where('ip', $ip)->exists());

5. Custom checker for app-specific rules

Symfony: implement CheckerInterface, register with the bundle's checker tag (exact tag in config/services.xml), and the SpamConfig/SpamProtection service composes it automatically. Good candidates: disposable-email domains, repeated URLs in message bodies, Cyrillic-only text on an English site, "SEO" keywords in the name field.

final class DisposableEmailChecker implements CheckerInterface
{
    public function check(/* form or request, per interface */): bool
    {
        $email = /* read from submitted data */;
        return in_array(Str::after($email, '@'), self::DOMAINS, true);
    }
}

6. Laravel FormRequest pattern

If you are porting, keep it in the validation layer so policies and tests stay uniform:

class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'    => ['required', 'string', 'max:120'],
            'email'   => ['required', 'email'],
            'message' => ['required', 'string', 'max:5000'],
            'website' => ['prohibited'],                       // honeypot
            '_ts'     => ['required', 'integer', 'max:' . time(), 'min:' . (time() - 3600)],
        ];
    }

    public function withValidator($validator): void
    {
        $validator->after(function ($v) {
            if (time() - (int) $this->input('_ts') < 5) {
                $v->errors()->add('message', 'Please take a moment before submitting.');
            }
        });
    }
}

The prohibited rule on the honeypot is a neat Laravel idiom: it fails only when the field is filled.

7. Blade component for reuse

{{-- resources/views/components/antispam.blade.php --}}
<div aria-hidden="true" class="hidden">
    <input type="text" name="{{ $field ?? 'website' }}" value="" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="_ts" value="{{ now()->timestamp }}">

Usage: <x-antispam /> inside every public form. One component, one convention, no drift between forms.

8. Layering

Treat any anti-spam feature as a tier, not a silver bullet:

Tier Cost What it catches
Honeypot Free Naive bots, scrapers
Minimum time Free Headless scripts, instant posts
IP block Free (maintenance) Repeat offenders
Rate limit (throttle) Free Brute force, floods
Akismet / CleanTalk / reCAPTCHA Paid / privacy cost Content-level spam

The bundle covers the top three. Add throttle:5,1 on the route and you are at a solid 80% solution.

Gotchas and Tips

  • Framework mismatch is the headline gotcha. There is no Laravel service provider, no facade, no publishable config. Do not attempt to composer require this into a Laravel app expecting it to work; you will get a bundle class with no boot mechanism. Port the logic.
  • Signature drift. SpamProtectionInterface::isSpam() has taken either a Request, a FormInterface, or both across versions. Read src/SpamProtectionInterface.php in the version you actually installed — do not trust blog posts, including the patterns above.
  • Global form extension side effects. If the bundle's form type extension is enabled for FormType, it can inject fields into every form in the app, including login, registration, and search. If a form has strict validation, allow_extra_fields => false, or is a GET form, you will see confusing submissions. Scope the extension via the bundle config or restrict it to specific form types.
  • The honeypot must not trip password managers or accessibility tools. Use autocomplete="off", tabindex="-1", and aria-hidden="true". Better: use a field name that password managers ignore — website, url, company are risky; _hp or a randomized per-session name is safer.
  • Full-page caching breaks the timestamp. If you cache the rendered contact form (Varnish, Symfony HttpCache, Laravel response cache), _ts will be stale and every submission will look either instant or ancient. Render the anti-spam partial outside the cached fragment, or validate against a session value instead.
  • Client IPs behind proxies. Symfony's IpListChecker (and Laravel's $request->ip()) honor trusted-proxy config. Misconfigured proxies mean every request appears to come from a load balancer. Symfony: framework.trusted_proxies. Laravel: TrustProxies middleware. Blocklists are useless until you fix this.
  • Silent rejection needs observability. A pure return redirect(...) with no log means you will never know the false-positive rate. Log IP, form name, and a hash of the payload (not the payload itself) so you can tune thresholds later.
  • Don't over-tune minimum_time. Two seconds is invisible; ten seconds on an order form causes real abandonment. Three to five seconds is the useful band.
  • Time is not monotonic across a fleet. If you run multiple app servers, time() is fine (it is UTC), but any DateInterval math done in a request-scoped object with a per-request clock will drift. Keep the check purely on Unix timestamps.
  • Tests don't fill honeypots. Any E2E test (Panther, Laravel Dusk, Symfony functional tests with submit()) will trip the prohibited/honeypot rule if you naively mirror the production form. Override the check in the test environment, or the anti-spam config, and test both branches explicitly.
  • No JS is not a spam signal. A significant fraction of legitimate users browse without JS, and many bots run headless Chrome. Avoid "requires JS to pass" checks.
  • Version/pin discipline. This is a low-activity, low-star package. Pin a specific version, check the Symfony constraint in composer.json against your app (5.4/6.x/7.x), and read the CHANGELOG before any bump. The three checkers are simple enough that forking them into your codebase is a realistic option — one less external dependency on a quiet repo.
  • Extension point to remember: a custom checker is the right place for business-specific rules. Resist adding conditions inside the controller; controllers should stay at "handle → validate → isSpam → branch."
  • Privacy note. The bundle itself does not call out to third parties, which is its main advantage over reCAPTCHA. If you layer Akismet on top for content-level spam, you are now shipping user messages (and IPs) to a third party — disclose it, especially for EU forms.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log