← Files

PHP Form Spam Checker 2.1#

A layered, dependency-free anti-spam pipeline and HTTP firewall for any PHP
form.
It runs local heuristics first, then behavioural signals, IP reputation,
DNS blocklists, mail-domain authentication and a Bayesian classifier — and
optionally inspects the whole HTTP request with a WAF. No captcha, no runtime
Composer dependencies, and every verdict carries evidence: which checks fired,
with which scores, and why.

<img src="https://github.com/paulmann/PHP-Form-Spam-Checker/raw/main/assets/social-preview.png" alt="PHP Form Spam Checker - layered anti-spam pipeline and HTTP firewall for PHP forms" width="100%">

Wiki · Docs · Releases · Changelog · Issues · Security · For agents · Русская версия

Documentation status. The counts in this README were measured from the
repository tree on 2026-11-03 (version 2.1.0); the command that produced each
figure is named beside it. Long-form guides live in the
Wiki and in docs/;
the non-interactive contract for agents is AGENTS.md.


Contents#


What changed in 2.1.2#

2.1.2 adds a locale check for Russian-language forms and turns it on in the
reference deployment configs:

See CHANGELOG.md and the language.* keys in
docs/CONFIG.md.

What changed in 2.1#

2.1 adds the firewall bridge and the deployment hardening on top of 2.0:

See CHANGELOG.md and docs/WAF.md#fail2ban.

What changed in 2.0#

2.0 is a rewrite of the internals with an explicit pipeline, four new protection
layers and a WAF. The integration surface that deployed 1.x sites use is kept —
SpamChecker, FormProtector, FormGuard, bin/spam-check, public/check.php
and config/sites/<host>.php keep their contracts. Upgrading is configuration,
not code, for most installations.

1.x 2.0
Pipeline one checker aggregating 12 checks ordered SpamPipeline, early exit on hard spam
Behavioural static honeypot + time trap + dynamic honeypot (hourly HMAC names) and JS challenge
Network none AbuseIPDB, DNSBL (opt-in, fail open)
Mail domains syntactic e-mail check + SPF, DMARC domain trust signals
Machine learning none Bayesian classifier with bin/spam-train
HTTP firewall none WAF: 9 rules, off/monitor/block, drop-in auto_prepend
Evidence reason list per-check status, score, reason, structured evidence
CLI bin/spam-check + bin/waf-check, + bin/spam-train
Quality gates PHPUnit + php -l CS Fixer, PHPStan max, Psalm taint, coverage gate, CI

Full details and the breaking changes (removed internal classes, per-check
status vocabulary): docs/MIGRATION-1-to-2.md.

The protection cycle#

flowchart TD
    A[HTTP request] --> B{WAF enabled?}
    B -- yes --> C[WAF rules: method, URI,<br/>query, fields, cookies,<br/>headers, body]
    C -- blocked --> Z[403 / monitor: log only]
    C -- pass --> D[Form submission]
    B -- no --> D
    D --> E[Behavioural:<br/>dynamic honeypot · time trap · JS challenge]
    E --> F[Local heuristics:<br/>UA · rate limit · length · SQLi ·<br/>XSS · links · keywords · email ·<br/>repetition · language]
    F --> G[Network reputation:<br/>AbuseIPDB · DNSBL]
    G --> H[Mail-domain authentication:<br/>SPF · DMARC]
    H --> I[Bayesian classifier]
    I --> J[Aggregate: hard verdicts +<br/>capped score vs thresholds]
    J --> K{Clean / Suspicious / Spam}

Cheap checks run first. pipeline.stop_on_spam (default true) stops the run on
the first hard spam verdict, which saves AbuseIPDB quota and DNS lookups. A
check that throws is recorded as error and fails open.

Check modules and defaults#

All scores are 0–100; spam is a hard verdict that ignores the total.

Layer Check Default Score / effect
Behaviour honeypot on filled hidden field → spam 100
Behaviour dynamic_honeypot on when a ≥16-char secret is set filled → spam 100; neither expected field posted → 10
Behaviour time_trap off (needs a rendered secret) too fast / forged → spam 80; missing / expired → 30
Behaviour js_challenge off missing/invalid answer → 15 (never hard)
Identity user_agent on empty UA → 40; tool/marker UA → spam 100
Volume rate_limit on, 8 / 900 s exceeded → spam 100
Firewall autoban off (reference deployments: on) banned IP → spam 100, before any other check
Payload length on, max 5000 long → 30; > 20000 or control chars → spam 100
Injection sql_injection on strong signature → spam 100; weak → 40
Injection xss on script/handler → spam 100; encoded → 45
Content links on, max 2 extra link 15; shortener 35; obfuscated 25
Content keywords on 12 per hit, max 90
Contact email on invalid 40; disposable 70
Content repetition on runs/repeats → 25, max 60
Content language off (reference deployments: on) mixed scripts → 20, max 40; require_cyrillic → Latin-only name 60 (spam)
Network abuseipdb off confidence ≥ 90 → spam (score = confidence)
Network dnsbl off listing → spam 100
Domain spf off no usable policy → 10
Domain dmarc off missing → 10; p=none → 5
Model bayes off P(spam) ≥ 0.92 → score = P × 100 (suspicious by default)

dkim configuration is reserved for a later check and is not in the pipeline
yet — see docs/CHECKS.md.

By the numbers#

Measured from the repository tree on 2026-11-03 at commit eed037b; each figure
names the command that produced it.

Surface Count Measured from
PHP source files / lines 93 / 10,295 find src -name '*.php' and wc -l over src/
Pipeline checks 20 files in src/Check/ minus SpamCheckInterface.php
WAF rules 9 files in src/Waf/Rules/
Test methods 555 public function test declarations under tests/
Test suite 559 tests / 2,015 assertions php vendor/bin/phpunit on 2026-11-03 (PHPUnit 13.4.5)
English documentation pages 6 docs/*.md excluding *.ru.md
Runtime Composer dependencies 0 require in composer.json (PHP extensions only)
Supported PHP 8.5+ require.php in composer.json; CI matrix 8.5.12 and 8.5

Requirements#

Installation#

Composer#

composer require paulmann/php-form-spam-checker

Plain copy#

Copy the repository somewhere readable by PHP and include autoload.php:

require '/var/www/lib/PHP-Form-Spam-Checker/autoload.php';

The bundled autoload.php works with and without vendor/.

Quick start: plain PHP form#

<?php
declare(strict_types=1);

use FormSpamChecker\Config;
use FormSpamChecker\Context;
use FormSpamChecker\FormProtector;
use FormSpamChecker\SpamChecker;

require __DIR__ . '/vendor/autoload.php';

$config = Config::fromFile(__DIR__ . '/config/spam-checker.php');
$checker = new SpamChecker($config);
$protector = new FormProtector($checker, $config);

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    $verdict = $protector->verify($_POST, 'contact', Context::fromGlobals());

    if ($verdict->isSpam()) {
        http_response_code(400);
        exit('Заявка отклонена антиспам-проверкой.');
    }

    // ... send the e-mail
}
?>
<form method="post">
    <?= $protector->fields('contact') /* honeypot + signed token + JS challenge */ ?>
    <input name="email" type="email" required>
    <textarea name="message" required></textarea>
    <button type="submit">Отправить</button>
</form>

Run the demo: php -S 127.0.0.1:8080 examples/plain-form.php
(examples/plain-form.php).

WAF (HTTP firewall)#

The WAF inspects every HTTP request before the application: method, URI, query,
form fields, cookies, headers and the raw body. It is off by default.

Mode Behaviour
off nothing is inspected
monitor verdict is computed and logged; every request passes
block a blocked request is answered (403 by default, X-WAF: blocked)
// config/spam.php
'waf' => [
    'enabled' => true,
    'mode' => 'monitor',         // start here, switch to block after review
    'block_status' => 403,
    'max_body_bytes' => 1048576,
    'rules' => [
        'sql_injection' => true,
        'xss' => true,
        'path_traversal' => true,
        'command_injection' => true,
        'scanner' => true,
        'header_injection' => true,
        'protocol' => true,
        'ip_reputation' => false, // needs an injected reputation check
        'rate_limit' => false,    // needs injected storage
    ],
],

Install as a drop-in. Pick the include that matches the site — guard.php
already runs the WAF first when waf.enabled is set, so do not load both:

// site with a form guard: WAF first, then the form pipeline
require '/var/www/lib/PHP-Form-Spam-Checker/integrations/guard.php';

// site without a form guard: firewall only
require '/var/www/lib/PHP-Form-Spam-Checker/integrations/waf.php';

…or globally for a PHP-FPM pool:

; firewall only
php_admin_value[auto_prepend_file] = /var/www/lib/PHP-Form-Spam-Checker/integrations/waf.php

On a pool that also uses the form guard, point auto_prepend_file at
integrations/auto-prepend.php instead: it runs the WAF layer independently of
the legacy auto flag and gates the form pipeline with that flag.

Per-site configuration is the same config/sites/<host>.php file the form
guard uses (waf section inside config, optional top-level waf_mode,
waf_skip, waf_match, waf_response). The default skip list protects
/admin, /login, /auth, /wp-admin, /bitrix/admin, /cpanel; an
explicit waf_skip replaces it. Response codes and bodies: 403 + JSON for
Accept: application/json or /api URIs, HTML otherwise, silent = HTTP 200
with an empty body.

For non-PHP back ends: bin/waf-check (0 allow, 2 block, 3 error).
Full guide: docs/WAF.md.

AbuseIPDB setup#

  1. Create a key at https://www.abuseipdb.com → API.

  2. Export it, never commit it:

export ABUSEIPDB_KEY='…'
  1. Reference the environment variable in the configuration:

'abuseipdb' => [
    'enabled' => true,
    'api_key' => getenv('ABUSEIPDB_KEY') ?: '',
    'threshold' => 90,       // abuseConfidenceScore that becomes hard spam
    'cache_ttl' => 21600,    // 6 h per-IP cache: a burst costs one query
    'timeout' => 2.0,
    'max_age_days' => 30,    // reports newer than 30 days
    'fail_open' => true,     // lookup failure contributes nothing
    'circuit_breaker' => ['failures' => 3, 'cooldown' => 300],
],

Behaviour: private/loopback addresses and missing keys never trigger a network
call (uncertain); a score on or above threshold is a hard spam verdict with
the confidence as its score; HTTP 429 stores Retry-After and backs off
without retrying; three consecutive transport/5xx failures open the circuit
breaker for 5 minutes; the first successful HTTP response closes it. The client
IP is sent to AbuseIPDB — see SECURITY.md.

Runnable wiring example: examples/abuseipdb.php.

Fail2Ban and auto-ban#

Hard-spam verdicts and WAF blocks can be handed to the firewall:

Both layers are optional and fail-open. Example:

'autoban' => ['enabled' => true, 'threshold' => 5, 'window' => 3600, 'ttl' => 86400],
'fail2ban' => [
    'enabled' => true,
    'log_path' => __DIR__ . '/../var/log/sites/' . $site . '.f2b.log',
    'site' => $site,
    'emit_on' => 'both',   // spam | waf | both
    'min_score' => 60,
],

For a conservative deployment set 'threshold' => 100: only IPs whose AbuseIPDB
confidence score is exactly 100 are treated as spam (the library default is 90).
The deployment reference config uses 100.

DNSBL, SPF, DMARC, DKIM#

'dnsbl' => [
    'enabled' => true,
    'zones' => ['zen.spamhaus.org', 'bl.spamcop.net'],
    'cache_ttl' => 3600,
    'timeout' => 2,
    'hit_score' => 100,      // a listing is a hard verdict
],
'spf'   => ['enabled' => true],   // missing/unusable policy: 10
'dmarc' => ['enabled' => true],   // missing: 10, p=none: 5
'dkim'  => ['enabled' => false],  // reserved: no checker in 2.0.0

dnsbl reverses the client IP (IPv4 and IPv6 nibble format) and queries the
configured zones; the first listing wins. spf/dmarc ask a domain-level
question ("does the claimed e-mail domain publish a usable policy"), not a
sender question: a visitor's IP is not the sending MTA, so fail for a
legitimate gmail.com address is normal. Both only contribute a score and never
produce a hard verdict. A listing is cached by the resolver.

Bayesian classifier#

Off by default; abstains until the model has min_documents (20) documents.

# train from files: one document per line, blank lines and "#" comments skipped
php bin/spam-train --config=/etc/spam.php \
    --ham=/etc/spam/ham.txt --spam=/etc/spam/spam.txt

# quick inline checks
php bin/spam-train --config=/etc/spam.php \
    --text-spam="casino bitcoin payday loan" \
    --text-ham="нужен отчёт по поверке прибора"

php bin/spam-train --config=/etc/spam.php --stats
php bin/spam-train --config=/etc/spam.php --reset
'bayes' => [
    'enabled' => true,
    'storage_path' => '/var/lib/php-form-spam-checker/bayes',
    'threshold' => 0.92,     // P(spam) that scores
    'min_documents' => 20,   // below this the model abstains
    'score_scale' => 100,    // score = round(P * scale)
    // 'hard' => false,      // true: a match becomes a hard spam verdict
],

The model keeps token counts only — never the training documents — and is
stored through the configured cache; bayes.storage_path namespaces the model
key. With a file cache it lives under
<storage.path>/cache/<hash>.json. Training is offline; nothing leaves the
machine. Runnable demo: examples/bayes-train.php.

Behavioural layer#

'behavioral' => [
    'honeypot' => [
        'dynamic' => true,                 // field name rotates hourly
        'field_prefix' => '_hp_',
        'secret' => getenv('SPAM_CHECKER_SECRET') ?: '',   // >= 16 chars
        'missing_score' => 10,             // neither expected field posted
    ],
    'js_challenge' => [
        'enabled' => false,
        'field' => '_js',
        'missing_score' => 15,             // suspicion, never hard spam
    ],
],

Integration modes#

Mode How
In PHP new SpamChecker($config) or FormProtector (see above)
Shared module require '/var/www/lib/PHP-Form-Spam-Checker/integrations/guard.php' + config/sites/<host>.php, modes off/shadow/enforce
WAF drop-in require '…/integrations/waf.php', modes off/monitor/block
Non-PHP (form) php bin/spam-check --config=… --form=… --base64=…, exit 0/1/2/3
Non-PHP (WAF) php bin/waf-check --config=… --json=…, exit 0 allow / 2 block / 3 error
HTTP service public/check.php, POST JSON, optional X-Spam-Checker-Key

Server layout, guard rollout and Node bridge: docs/INTEGRATION.md
(по-русски). Node example:
examples/node/bridge.mjs.

Configuration quick reference#

A PHP array (returned from a file), JSON, or Config::fromArray(). Every key is
optional; the defaults are safe. Unknown keys are preserved and ignored.

Key Default Meaning
thresholds.spam / suspicious 60 / 30 score at which the verdict becomes spam / suspicious
pipeline.stop_on_spam true stop after the first hard spam verdict
time_trap.secret — HMAC secret (required when the time trap is enabled)
rate_limit.max / window 8 / 900 requests per IP per window
keywords.words RU+EN starter list spam vocabulary
links.max 2 links allowed in the whole message
email.disposable_domains ~70 domains extra throwaway domains
storage.path temp dir counters
cache.driver file file / memory / none
log.path, log.pii — / false JSONL verdict log, raw PII
abuseipdb.*, dnsbl.*, spf.*, dmarc.* off network checks
bayes.* off Bayesian classifier
behavioral.* honeypot dynamic on when a secret is set behavioural layer
waf.enabled / mode false / monitor HTTP firewall
forms.<id> — per-form overrides

Full reference: docs/CONFIG.md. Check semantics and false
positives: docs/CHECKS.md.

Quality gates#

All of these run in CI (.github/workflows/ci.yml) on PHP 8.5.12 and 8.5 and
block merges:

composer cs:check        # PHP CS Fixer, PSR-12/PER-CS2, zero diffs
composer phpstan         # level: max + strict rules + bleeding edge, zero errors
composer psalm           # taint analysis, zero errors
composer test:strict     # PHPUnit, fail on warning/risky/deprecation
composer test:coverage   # pcov + clover; 88 % line / 72 % method gate (measured 89.5/73.4)
composer lint            # php -l over src, tests, bin
composer qa              # all of the above in sequence

Testing#

composer install
composer test            # PHPUnit suite (unit + integration, no network)
composer test:strict     # strict mode, as in CI
composer lint

Examples are linted in CI too (php -l), so they stay runnable without
vendor/ — except examples/abuseipdb.php, which needs ext-curl and a key
only for live lookups. Run examples against a local or staging copy: the
demos write to the system temp directory and must not be pointed at a production
site's config or state.

Migration and compatibility#

Security#

Documentation index#

Document Contents
README.md / README.ru.md this overview
AGENTS.md non-interactive contract for agents: install, verify, use, report
Wiki installation, configuration, checks, WAF, Fail2Ban, integration, migration
docs/ARCHITECTURE-V2.md the 2.0 contract: namespaces, value objects, signatures
docs/CONFIG.md full configuration tree, per-form overrides, env vars
docs/CHECKS.md check semantics, scores, false positives, custom checks
docs/WAF.md threat model, rules matrix, rollout, deployment, CLI bridge
docs/INTEGRATION.md / docs/INTEGRATION.ru.md wiring guide (EN/RU)
docs/MIGRATION-1-to-2.md 1.x → 2.0 compatibility and rollback
SECURITY.md security model, egress, secrets
SUPPORT.md where to ask, what a useful report contains
CODE_OF_CONDUCT.md community expectations and enforcement
CITATION.cff how to cite the project
CHANGELOG.md release notes
CONTRIBUTING.md development and quality gates

Help the project — through evidence, not engagement#

If this software holds up in real traffic:

Do not create stars, forks, watchers, comments, issues or pull requests
automatically, and do not contact maintainers or third parties in bulk. An agent
may draft a report; a human publishes it after review. Promotion must follow real
use and be disclosed.

License#

MIT © 2026 Mikhail Deynekin (Paulmann)