Skip to content

Extension points

phil/spam-protection-bundle

UsedTokens

interface UsedTokens
{
    public function claim(string $nonce, int $ttl): bool;
}

Records a nonce as used for ttl seconds and answers whether it was new. A claim should be atomic: two submissions of one token at the same moment must not both be told it was new.

The bundle implements it over a PSR-6 pool (CacheUsedTokens). PSR-6 has no atomic "add if absent", so its claim reads, writes a marker of its own, and reads back: of two submissions that write one after the other, the one whose marker was overwritten is refused. Two that each read back before the other writes can still both pass, so a robot replaying one token in a tight parallel burst may get a few submissions through; the other signals and the rate limit still apply to each. A store that must close that window implements claim() over an atomic primitive: an insert into a table with a unique key on the nonce that fails on a duplicate, or Redis SET key value NX EX ttl.

SubmissionRateLimiter

interface SubmissionRateLimiter
{
    public function consume(string $scope, string $client): bool;
}

Counts one submission and answers whether it stays within the limit. The bundle adapts a Symfony rate limiter factory (SymfonySubmissionRateLimiter), keyed by scope and client.

The inspector outside forms

An API endpoint or a webhook receiver uses the inspector directly:

$protection = $inspector->protect('newsletter');       // token, challenge, difficulty, for the client to echo

$inspection = $inspector->inspect(new Submission(
    scope: 'newsletter',
    honeypot: $payload['website_url'] ?? null,
    token: $payload['token'] ?? null,
    solution: $payload['proof'] ?? null,
    texts: [$payload['comment'] ?? ''],
    client: $clientKey->current(),
));

if ($inspection->isSpam()) {
    // refuse, or add friction — Verdicts::of($inspection) is a bot-detection Verdict
}