pecotamic / antispam
Silent server-side timing and content based spam protection for Statamic forms
Requires
- php: ^8.2
- statamic/cms: ^5.0|^6.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Silent server-side spam protection for Statamic forms.
Every submission is examined by a set of rules. Each rule that objects adds its
configured weight to a score; once the score reaches the threshold, the
submission is silently discarded through Statamic's FormSubmitted event. Bots
receive the regular success response.
Installation
composer require pecotamic/antispam
That is all. Every Statamic form on the site is protected from that moment, with no template to edit and nothing to publish.
The addon places what the browser side needs — a tracking pixel and a small
frontend module — just before </body>, on pages carrying a protected form.
Pages without one are passed through untouched. Publish the config only if you
want to change something:
php artisan vendor:publish --tag=pecotamic-antispam-config
Configs are merged recursively, so a published config needs to name only what it changes; everything else is inherited, new rules included.
Rules
| Rule | Objects when |
|---|---|
pixel |
The form page was never loaded with its subresources. |
interaction |
No valid proof of human interaction accompanies the submission. |
timing |
The submission arrives faster than minimum_fill_time, later than maximum_fill_time, or without the timing cookie. |
rate_limit |
One address sends more than maximum submissions within window seconds. |
duplicate |
The same content was already submitted within window seconds. |
patterns |
A configured regular expression matches the submitted text. |
links |
A value contains more links than maximum allows. |
script |
A value contains characters outside the allowed writing systems. |
gibberish |
A single word is long enough to be prose yet has too few vowels to be a word. |
shouting |
A value of prose length is written predominantly in capitals. |
email |
An address uses a throwaway mail domain, or has no MX record when check_mx is on. |
The rule set ships with the addon and is not assembled per site. Sites adjust weights, thresholds and the individual options instead — there is no place where a project needs rule code of its own.
Frontend
The package ships the form JavaScript alongside the PHP and serves it itself, from a fingerprinted route:
/!/pecotamic-antispam/js/<fingerprint>/contact-form.js
Two things follow. Client and server are released as one version, so a signal
the client sends and a signal the server checks cannot drift apart. And a fix
to the frontend reaches a site through composer update alone — no build on
the site that has to succeed first.
The files are plain JavaScript with JSDoc types, checked by tsc --checkJs.
There is no build, so the file served is the file in the repository, and no
compiled artefact can go stale against its source.
How a form is recognised
By its action. Every form Statamic renders posts to /!/forms/<handle>, and
that handle is what the addon matches against the forms configuration — both
to decide which pages to add the markup to, and to build the selector the
frontend attaches by.
Nothing else is assumed. No class name, no id, no markup convention: a form
built with {{ form:create }} is recognised whatever your template looks like.
The flip side is that a form which does not post to /!/forms/… — one posting
to a route of your own, or one whose action is set by JavaScript after the page
loads — is not a Statamic form as far as the addon is concerned, and is left
alone. Such a form is not protected, and its submissions never reach the rules.
What it touches, and what it does not
Only pages carrying a form listed under forms are modified. A page whose only
form is left out of that list — a calculator, a search, a form handled
elsewhere — comes through exactly as your templates wrote it.
Prose is not a form: an article that merely writes about /!/forms/contact
does not make its page a form page.
To keep the addon out of your markup altogether:
'inject' => false,
The rules that need no markup keep working; pixel and interaction stay
quiet unless you place the tag yourself.
Why the addon places the markup itself
Leaving it to a tag would add an integration step, and one that is easy to get
wrong. The pixel is an <img>, which is only valid in the body: placed in the
<head> it ends the head as far as the HTML parser is concerned, and
everything after it — analytics, meta and link tags — is reparented into the
body. Sites that collect their scripts in a stack rendered inside <head>
invite exactly that mistake. Injecting before </body> is correct whatever the
templates look like.
Configuring the frontend
Where a site needs different selectors or messages, the tag passes them on. It renders nothing and may sit anywhere on the page — the markup still goes where it belongs:
{{ antispam selector="form.enquiry" event_name="sent" }}
selector, error_selector, consent_field, event_name, success_class
and failure_class are accepted. Everything else — above all the proof field
name and endpoint — comes from the PHP config, so it exists once rather than
once per side.
Messages
The messages shown beside a field are translations, rendered by the server in the site's locale. English and German ship with the addon; add a language or change the wording the way you would for any package:
php artisan vendor:publish --tag=pecotamic-antispam-translations
Placing the markup yourself
If the automatic placement does not suit a layout, turn it off and place the tag instead:
'inject' => false,
{{ antispam }}
Inside the body, then — never in a stack rendered in <head>, for the reason
above. The addon says so after an install or update if the tag is missing.
What the frontend does and does not do
It validates for immediate feedback — required fields, address format, consent — and fetches the interaction proof. It runs no spam heuristic of its own: a heuristic maintained in two languages drifts apart, and a false positive in the browser blocks a real visitor behind an error message, while the server can weigh the same signal among others.
It leaves the honeypot field in the payload. Stripping it client-side leaves the server nothing to detect, and a stripped honeypot is indistinguishable from an empty one — the failure is silent and total. There is a test for exactly this.
Before the protection has reached anyone
The proof rules ask for evidence only the markup produces, and stay quiet until that markup has been seen doing its work — until a pixel has actually been fetched.
Weighted at 60 each, a missing pixel and a missing interaction proof clear the threshold together, so without this a site upgrading while its pages sit in a full static cache built before the markup existed would swallow every enquiry — silently, since the visitor is shown a success message either way.
What a proof does and does not prove
A proof says a browser asked for one, and that a plausible interval passed before the form arrived. It is not single-use: a bot could fetch one and reuse it. That is deliberate — the endpoint is open, so single-use would only force a bot to fetch one proof per submission rather than one per campaign, while costing a visitor whose first attempt failed validation their second.
Volume is what rate_limit and duplicate are for. The proof's job is to make
a blind POST — the overwhelming majority of form spam — fail.
The two proofs
pixel and interaction cover each other, which is why both are weighted
below the threshold. A visitor without JavaScript has the pixel; a visitor
whose ad blocker swallowed the pixel has the interaction proof. Either alone
gets them through. A blind POST has neither and exceeds the threshold on that
count alone.
Scoring
With the default weight of 100 against a threshold of 100, each rule rejects on its own. Lowering a weight turns a rule into a mere indication that only rejects in combination:
'threshold' => 100, 'rules' => [ 'gibberish' => ['weight' => 60, /* … */], 'shouting' => ['weight' => 60, /* … */], ],
Neither rule now rejects alone, but a submission that trips both does.
Set a rule's weight to 0 to switch it off entirely, and list field handles
under except to skip individual fields.
Tuning against real traffic
Before tightening weights, let the addon report what it would have caught:
'log' => 'scored',
This logs accepted submissions that a rule objected to, alongside the rejected
ones, each with the score and the reasons behind it. Those near misses are the
material to set a threshold against. 'rejected' (the default) logs only
discarded submissions, 'none' disables logging.
Which fields a rule examines
This follows from the form blueprint rather than from configuration. A field declared as a telephone number, an email address or a URL is skipped by the rules that judge written language, because it is not written language:
| Blueprint says | skipped by |
|---|---|
input_type: tel, number, date |
gibberish, shouting, links |
input_type: email |
gibberish, shouting |
input_type: url |
links |
select, checkboxes, radio, assets, … |
every content rule |
So a website field may hold a website, an address written in capitals is not mistaken for shouting, and a note typed into a telephone field is left alone — without a list of field handles that has to be kept in step with the forms across every site.
Two rules deliberately ignore this. script is applied to everything a person
typed, addresses and URLs included: a cyrillic character in an email address is
as telling as one in a message. And email finds addresses by their shape, so
it catches one pasted into a message body as readily as one in the address
field.
A field the blueprint does not describe is examined. Waving a field through because it is unknown would be the wrong way round.
except remains for anything left over: field handles a rule should skip
whatever the blueprint says about them.
False positives
The content rules are deliberately conservative, because a wrongly discarded submission disappears without anyone noticing — the visitor sees a success message either way.
gibberish judges words individually rather than whole values. Measured in one
piece, a practice name such as "MVZ Dr. Schmidt GmbH" has barely a vowel per
four letters and would look like a keyboard mash; its individual words are all
short enough to be left alone. Words are also transliterated to ASCII before
their vowels are counted, so "Schröder" is measured as "Schroder" — against a
plain [aeiou] class a good share of German names would be rejected.
shouting ignores anything below prose length for the same reason: short
acronym-heavy values reach a high capital ratio without being shouted.
links does not treat a bare "domain.tld" as a link, because that would match
every email address passed through as a value. Forms that legitimately ask for
a website belong under that rule's except rather than a raised maximum.
rate_limit allows generously, because mobile networks put many subscribers
behind one address. It needs the application's trusted proxy configuration to
see real client addresses when the site sits behind a proxy or CDN.
interaction is weighted below the threshold by default. Forms still submit
without JavaScript, and a visitor who has it disabled should not be turned away
on that alone — but a missing proof together with any second indicator is
enough. Raise it to the threshold on sites whose forms require JavaScript
anyway.
email keeps its MX lookup off by default: it puts a DNS round trip in the
request path, and a resolver outage would make every address look invalid and
take the form down with it.
Static caching
With Statamic's full measure static caching, pages are served straight from
disk without booting PHP, so the middleware that issues the timing cookie never
runs. The pixel request closes that gap: it reaches PHP whatever the page did,
and seeds the timing cookie when there is none — so {{ antispam }} is what
makes full static caching safe here.
Without the tag on a full-measure site, every submission is rejected as if it
had no cookie. Either add the tag, exclude the form pages from the cache
(statamic.static_caching.exclude), or set the timing rule's weight to 0.
The half measure strategy executes PHP on each request and works either
way.
Tests
composer install && vendor/bin/phpunit npm install && npm test
Live smoke test
bin/live-test.php submits a genuine, a too-fast and a cookie-less submission
against a running site and reports how each was handled.