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...
This is a Symfony bundle, not a Laravel package. Before you invest time, decide which side of the fence you are on:
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.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:
src/SpamProtection.php and src/SpamProtectionInterface.php — the public entry point and its exact signature.src/Checker/ — HoneypotChecker, MinimumTimeChecker, IpListChecker. This is the entire product; read all three in under 10 minutes.config/services.xml — service IDs and checker tags, which you need to register your own check.src/DependencyInjection/Configuration.php — the authoritative list of config keys (docs and code drift in low-activity packages).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.
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);
}
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');
}
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) }}
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.
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());
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);
}
}
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.
{{-- 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.
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.
composer require this into a Laravel app expecting it to work; you will get a bundle class with no boot mechanism. Port the logic.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.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.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._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.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.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.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 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.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.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.isSpam → branch."How can I help you explore Laravel packages today?