Aller au contenu

omnisense/omnishield

Omnishield: one contract for guarding a form - is this token valid (a captcha: ALTCHA, Turnstile, reCAPTCHA), is this content spam (Akismet), are this address, this e-mail, this name known for abuse (StopForumSpam, disposable e-mail domains) - whoever answers; and its Symfony bridge: a form field, a constraint, the ALTCHA challenge's route, a Twig function.

Étage
Famille
Version
1.x
Suite de conformité
branchée, CI non lue
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnishield:1.x-dev

Omnishield is one PHP contract for guarding a form, whoever answers. It asks three questions, and a gateway answers the one that concerns it:

  • is this token valid? - a captcha (ChallengeInterface): ALTCHA, Cloudflare Turnstile, Google reCAPTCHA;
  • is this content spam? - a classifier (ClassifierInterface): Akismet;
  • are this address, this e-mail, this name known for abuse? - a list (ReputationInterface): StopForumSpam, the disposable e-mail domains.
$widget = $registry->challenge('forms')->widget('contact');      // what the page shows: script, element, field
echo $widget->html();                                             // inside the <form>

$verdict = $registry->challenge('forms')->verify(Attempt::fromPost($_POST, $widget, $ip));
$verdict->passed;                                                 // false: $verdict->reasons - missing, invalid, duplicate...

$registry->classifier('comments')->classify($submission)->isSpam();
$registry->reputation('emails')->lookup(new Identity($ip, $email))->known;

The design is Payum's, as in the family's other cores: a provider's factory fills a Config with its name, its options and their defaults and builds a gateway; a registry holds the site's gateways by name. The core needs no framework: it requires PHP 8.2 and symfony/http-client-contracts (the interfaces, no client). A gateway that calls a provider requires symfony/http-client, a library that stands alone. A Symfony bridge is included (a bundle, a form field, a constraint, a route, a Twig function: Symfony); omnibridge/laravel wires the registry under Laravel (Laravel).

A gateway answers. It does not decide what a refusal means for the form, nor what to do when its provider does not answer: the application does. A refusal is a Verdict, a Classification, a Reputation - never an exception; a provider that did not answer is an UnreachableException, a key it refused an InvalidKeyException, and neither is ever taken for "fine". It is told strings

  • a token, an address, a text, a name - never an account, an entity or a request. What a gateway cannot do is a NotSupportedException, and its Capabilities say so beforehand.

Published as omnisense/omnishield, formerly glitchr/omnishield (and before 2026-10-10 glitchr/omniguard): the package replaces glitchr/omnishield, so a composer.json that still requires the old name resolves to this package. The namespace is Omnishield\; the providers keep the omnishield/ vendor and the Omnishield\<Provider>\ namespace.

When to use it

  • A contact form, a comment form, a sign-up, a sign-in after failed tries: any form a bot should not post, with the captcha chosen per site (or per consent: ALTCHA when the visitor refused third parties, Privacy).
  • Comments to moderate: a classifier holds the spam and drops the flagrant, and learns from the moderator's corrections (report()).
  • A sign-up: the e-mail's domain against the disposable list, the address against StopForumSpam.

It is not a firewall (StopForumSpam's terms forbid checking every visitor: ask about those who submit a form), and it keeps nothing of the visitors but the spent ALTCHA tokens.

Providers

Six packages. The columns are what each gateway's capabilities() says (read from the code), and what it calls.

Package Factory Answers Calls Third party Cookies Score Action checked Reports Reads Needs
omnishield/altcha altcha challenge (it issues its challenges: ChallengeIssuerInterface; texts: LocalizableInterface) nothing no no no yes, signed in the challenge - - hmac_key
omnishield/turnstile turnstile challenge Cloudflare's siteverify yes no no yes - - site_key, secret
omnishield/recaptcha recaptcha challenge Google's siteverify; Enterprise assessments yes yes (_GRECAPTCHA) score, enterprise modes score, enterprise modes - - site_key, secret; Enterprise site_key, project_id, api_key
omnishield/akismet akismet classifier comment-check, submit-spam, submit-ham, verify-key yes no - - yes - api_key
omnishield/stopforumspam stopforumspam reputation api; add to report yes no - - with api_key (its own report()) send: ip, email, name nothing; api_key to report
omnishield/disposable disposable reputation nothing (a list shipped in the package) no no - - - email nothing
omnisense/omnishield fixed all three, for tests (Testing\FixedGateway) nothing no no yes (1.0 or 0.0) yes yes ip, email, name nothing (pass)

Each provider's own manual lists its options, its answers and what was verified against the provider for real.

How this manual is organised

  • Installation: requirements, a whole script in plain PHP, a shared store of spent tokens.
  • Symfony: the bundle's configuration tree, its services, the form field, the constraint, the route, the served script, the widget's language, Twig; Laravel.
  • Models: Widget, Attempt, Verdict, Submission, Classification, Label, Identity, Reputation, Capabilities, the printer.
  • The three questions: the contracts, a captcha, a classifier, a list, writing a gateway and its conformance suite.
  • Privacy: who sees what, cookies, consent.
  • Configuration reference: the registry's configuration, every provider's options, the reserved keys.
  • Errors, Testing, the Docker harness, Verified, and not.

Proposer une modification docs/index.md

Installation

composer require omnisense/omnishield omnishield/altcha     # a captcha the site issues and checks itself: no key, no third party
composer require omnishield/disposable                      # disposable e-mail domains: a list, no call
composer require omnishield/turnstile omnishield/recaptcha   # Cloudflare's, Google's captcha
composer require omnishield/akismet omnishield/stopforumspam # a spam classifier, a list of reported abusers

Requirements

  • PHP 8.2 or later.
  • omnisense/omnishield requires symfony/http-client-contracts (^3.0) and nothing else.
  • omnishield/turnstile, omnishield/recaptcha, omnishield/akismet and omnishield/stopforumspam require symfony/http-client (6.4, 7 or 8); omnishield/altcha requires altcha-org/altcha (^2.3) and no HTTP client; omnishield/disposable requires nothing more.
  • Suggested, for what uses them: psr/cache (CacheReplayStore), phpunit/phpunit and symfony/http-client (the conformance suite), and for the Symfony bridge symfony/config, symfony/dependency-injection, symfony/http-kernel, symfony/form, symfony/validator, symfony/routing, symfony/translation, twig/twig.

Omnishield needs no framework. The core requires PHP and symfony/http-client-contracts - the HTTP client's interfaces, no client; a gateway that calls a provider requires symfony/http-client (a library, not a framework) and takes the application's client when it is given one. It runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).

Plain PHP

<?php // guard.php

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

use Omnishield\Altcha\AltchaGatewayFactory;
use Omnishield\Disposable\DisposableGatewayFactory;
use Omnishield\Model\Attempt;
use Omnishield\Model\Identity;
use Omnishield\Registry;
use Omnishield\Replay\InMemoryReplayStore;
use Omnishield\Stopforumspam\StopforumspamGatewayFactory;
use Omnishield\Turnstile\TurnstileGatewayFactory;

$registry = new Registry([new AltchaGatewayFactory(new InMemoryReplayStore()), new TurnstileGatewayFactory(), new StopforumspamGatewayFactory(), new DisposableGatewayFactory()], [
    'forms' => ['factory' => 'altcha', 'options' => ['hmac_key' => 'a-long-random-secret-of-the-site']],
    'cloudflare' => ['factory' => 'turnstile', 'options' => ['site_key' => '1x00000000000000000000AA', 'secret' => '1x0000000000000000000000000000000AA']],
    'reported' => ['factory' => 'stopforumspam'],
    'throwaway' => ['factory' => 'disposable'],
]);

// The page: a widget inside the form.
$widget = $registry->challenge('forms')->widget('contact');
echo $widget->tag, ' posts "', $widget->field, '"; through a third party: ', $widget->thirdParty ? 'yes' : 'no', "\n";

// The browser's part, done here: the ALTCHA library solves the challenge the widget carries.
$challenge = AltchaOrg\Altcha\Challenge::fromArray(json_decode($widget->attributes['challenge'], true));
$solution = (new AltchaOrg\Altcha\Altcha())->solveChallenge(new AltchaOrg\Altcha\SolveChallengeOptions(challenge: $challenge, algorithm: AltchaGatewayFactory::algorithm('PBKDF2/SHA-256')));
$_POST = ['message' => 'Hello', 'altcha' => (new AltchaOrg\Altcha\Payload($challenge, $solution))->toBase64()];

// The form's handler.
foreach (['first post', 'the same again'] as $when) {
    $verdict = $registry->challenge('forms')->verify(Attempt::fromPost($_POST, $widget, '192.0.2.10'));
    printf("%s: %s\n", $when, $verdict->passed ? 'passed' : 'refused ('.implode(', ', $verdict->reasons).')');
}
$verdict = $registry->challenge('cloudflare')->verify(new Attempt('XXXX.DUMMY.TOKEN.XXXX', '192.0.2.10'));
printf("Turnstile, Cloudflare's testing keys: %s, from %s\n", $verdict->passed ? 'passed' : 'refused', $verdict->hostname);

foreach (['185.220.101.1' => null, '91.186.18.61' => 'someone@mailinator.com'] as $ip => $email) {
    $visitor = new Identity($ip, $email);
    foreach (['reported', 'throwaway'] as $list) {
        $reputation = $registry->reputation($list)->lookup($visitor);
        printf("%-9s %-14s %-24s %s\n", $list, $ip, $email ?? '-', $reputation->known ? 'known: '.implode(', ', $reputation->reasons).' (confidence '.$reputation->confidence.')' : 'unknown');
    }
}
$ php guard.php
altcha-widget posts "altcha"; through a third party: no
first post: passed
the same again: refused (duplicate)
Turnstile, Cloudflare's testing keys: passed, from example.com
reported  185.220.101.1  -                        known: ip, tor (confidence 95.29)
throwaway 185.220.101.1  -                        unknown
reported  91.186.18.61   someone@mailinator.com   unknown
throwaway 91.186.18.61   someone@mailinator.com   known: email, disposable (confidence 100)

(as answered on 2026-10-07, Cloudflare and StopForumSpam called for real)

That is all there is to it:

  • a factory per gateway package, which takes what its gateway works with: the store of spent tokens for ALTCHA, an HTTP client for those that call a provider (one is created when none is given);
  • the registry, built by hand from the factories and the gateways' options, by name;
  • the gateways it gives, each answering its question: challenge(), classifier(), reputation() give a gateway as the contract that asks it (gateways).

No class of a framework is loaded on the way - a test of this package checks it in a process of its own (Tests/BareTest.php), and so does docker compose run --rm omnishield bare (harness).

What the application does around a gateway

  1. Show the captcha's widget in the form - $widget->html(), or its parts in your own markup. When it reaches a third party ($widget->reachesOthers(): Turnstile, reCAPTCHA), name it and wait for the visitor's consent first (privacy).
  2. Verify the token on submission - Attempt::fromPost($_POST, $widget, $ip) reads it under the widget's field - and refuse the form when the verdict did not pass.
  3. Ask the lists about the visitor (Identity: address, e-mail, name) and classify what they wrote (Submission), when it is worth it: a comment, a sign-up.
  4. Decide what a provider that does not answer means for this form (UnreachableException): refuse it, or let it through and look later. Omnishield never decides it for you.

The address is the visitor's as your application sees it: behind a proxy or a load balancer, the one it forwarded (Symfony: trusted_proxies), not the proxy's.

A shared store of spent tokens

ALTCHA cannot tell a solution it saw before: the gateway remembers each one in a ReplayStoreInterface until it would have lapsed. InMemoryReplayStore remembers within one process - right for a test, a worker, the script above; behind PHP-FPM every request starts with an empty one, and a solution could be posted twice. Share one:

use Omnishield\Replay\CacheReplayStore;
use Symfony\Component\Cache\Adapter\RedisAdapter;   // any PSR-6 pool: filesystem, Redis, Memcached, APCu

new AltchaGatewayFactory(new CacheReplayStore(new RedisAdapter(RedisAdapter::createConnection('redis://localhost'))));

A PSR-6 pool has no atomic "add if absent": two requests posting the same solution within the same microseconds could both pass. Where that matters, implement ReplayStoreInterface::spend() on something atomic - a table with a unique key, Redis's SET NX.

In a framework

  • Symfony: Omnishield\Bridge\Symfony\OmnishieldBundle registers the factories, builds the registry from config/packages/omnishield.yaml, makes each gateway injectable by its name, and gives a form field, a constraint, a route and a Twig function: see Symfony.
  • Laravel: omnibridge/laravel's Shield\ShieldServiceProvider builds the registry from config/omnishield.php (the same keys) with the gateways that call no provider: see Laravel.
  • Any other: build the Registry once, where the framework builds its services, as the script above does; give it the framework's HTTP client and a shared store.

Errors

A provider that does not answer, a key it refuses, an option missing: Errors lists every exception and when each is thrown.

Proposer une modification docs/installation.md

Symfony

Omnishield runs without a framework (installation); in a Symfony application its bridge does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel, and for the pieces below symfony/form, symfony/validator, symfony/routing, symfony/http-foundation, twig/twig - are not required by omnisense/omnishield: the application has them, each piece is registered only when its component is installed, and nothing of them is loaded outside Symfony.

Register Omnishield\Bridge\Symfony\OmnishieldBundle (no Flex recipe):

// config/bundles.php
return [
    // ...
    Omnishield\Bridge\Symfony\OmnishieldBundle::class => ['all' => true],
];
# config/packages/omnishield.yaml
omnishield:
    gateways:                                  # by name: a factory and its options
        forms:
            factory: altcha
            options: { hmac_key: '%env(ALTCHA_HMAC_KEY)%' }
        comments:
            factory: akismet
            options: { api_key: '%env(AKISMET_API_KEY)%', site: 'https://example.org' }
        emails:
            factory: disposable
            options: { allow: '%env(default::DISPOSABLE_ALLOW)%' }
    challenge:
        gateway: forms                         # the default captcha; the first gateway when left out
        unreachable: reject                    # or accept: a provider that does not answer lets the form through
    serve_scripts: true                        # ALTCHA's widget served by the site, not a CDN
    replay:
        pool: cache.app                        # where spent tokens are remembered (PSR-6)
        # service: App\Security\SpentTokens   # or a ReplayStoreInterface of yours, atomic

when@test:
    omnishield:
        gateways:
            forms: { factory: fixed }          # passes every token but an empty one; { pass: false } refuses all

Every omnishield/* package installed registers its factory, autowired: the application's http_client is given to those that call a provider (none: one of their own), the store of spent tokens to ALTCHA. An application's own gateway - a class implementing GatewayFactoryInterface - is registered too, autoconfigured, and can be named as a factory. fixed (Testing\FixedGateway) is always there, for tests.

What is autowired:

Service
ChallengeInterface $forms, ClassifierInterface $comments, ReputationInterface $emails, GatewayInterface $forms one gateway by the argument's name (the configured name)
Registry every configured gateway by name: get(), challenge(), classifier(), reputation(), has(), names(), options(), create()
ReplayStoreInterface the store of spent tokens: replay.service, else a CacheReplayStore on replay.pool when the application has it, else this process's memory

Nothing is built when the container compiles: a gateway is built the first time it is asked for, and an option left empty only shows then (InvalidConfigException).

The configuration tree

As Symfony's Config component resolves it (the bundle's configure(), dumped with YamlReferenceDumper in the workspace on 2026-10-26):

omnishield:

    # The gateways, by name: a factory (altcha, turnstile, recaptcha, akismet, stopforumspam, disposable, fixed...) and its options.
    gateways:

        # Prototype
        name:
            factory:              ~ # Required
            options:              []
    challenge:

        # The captcha used when none is named: the first configured gateway when left empty.
        gateway:              null

        # When the provider does not answer: the form is refused (reject), or let through (accept).
        unreachable:          reject # One of "reject"; "accept"

    # Serve the widgets' scripts the gateway packages ship from the site itself (ALTCHA's at /omnishield/altcha/<version>/altcha.min.js), and make it their gateways' default: the page reaches nobody. false: the packages' defaults (a CDN).
    serve_scripts:        true
    replay:

        # A ReplayStoreInterface service of the application's: an atomic store (a unique key, SET NX).
        service:              null

        # Otherwise a PSR-6 cache pool, when the application has it; this process's memory when it has not.
        pool:                 cache.app
Key Type Default
gateways.<name>.factory string, required, not empty - a factory's name (providers), or an application's own factory's getName()
gateways.<name>.options any value (a map) [] handed to the factory's create() (Configuration); not validated at compile time
challenge.gateway string or null null: the first configured gateway the default of ChallengeType, PassesChallenge and omnishield_widget(); the parameter omnishield.challenge.gateway
challenge.unreachable reject or accept reject the validator's answer when the provider does not answer
serve_scripts bool true ALTCHA's script served by the site and made the default script of every altcha gateway that names none
replay.service service id or null null a ReplayStoreInterface of the application's
replay.pool service id or null cache.app a PSR-6 pool for CacheReplayStore; a missing service: InMemoryReplayStore

Services

Service id Registered when
Omnishield\Registry always public; built from the tagged factories and gateways
omnishield.gateway.<name> per gateway Registry::get('<name>'), the first time it is injected; aliased for the argument $<name> typed GatewayInterface, ChallengeInterface, ClassifierInterface or ReputationInterface
each provider's factory (Omnishield\Altcha\AltchaGatewayFactory...) and Omnishield\Testing\FixedGatewayFactory its package is installed autowired, tagged omnishield.gateway_factory; $http is the application's http_client (null without one)
Omnishield\Replay\ReplayStoreInterface always replay.service, or OmnishieldBundle::replayStore() on replay.pool
Omnishield\WidgetPrinter always tagged kernel.reset: one page's scripts, reset between two requests of a worker
Omnishield\Bridge\Symfony\WidgetLocalizer always public; the translator and the request stack when the container has them
Omnishield\Bridge\Symfony\Form\ChallengeType symfony/form tagged form.type
Omnishield\Bridge\Symfony\Validator\PassesChallengeValidator symfony/validator tagged validator.constraint_validator
Omnishield\Bridge\Symfony\Controller\ChallengeController symfony/routing and symfony/http-foundation public, controller.service_arguments; its route is imported by the application
Omnishield\Bridge\Symfony\EventListener\ScriptListener serve_scripts, omnishield/altcha, symfony/http-foundation kernel.request, priority 256
Omnishield\Bridge\Twig\OmnishieldExtension twig/twig tagged twig.extension: omnishield_widget(), omnishield_widget_data()

With TwigBundle, the bundle prepends Twig's configuration: the namespace @Omnishield (Bridge/Symfony/templates/) and, with symfony/form, the form theme @Omnishield/form.html.twig (the block omnishield_challenge_widget prints the field's widget). A class of the application implementing GatewayFactoryInterface is autoconfigured with the tag omnishield.gateway_factory. The bundle registers no console command and dispatches no event.

A captcha in a form

use Omnishield\Bridge\Symfony\Form\ChallengeType;

$builder
    ->add('message', TextareaType::class)
    ->add('captcha', ChallengeType::class, ['action' => 'contact']);

ChallengeType is mapped to nothing: it renders the gateway's widget (its own form theme, registered with Twig by the bundle) and carries the PassesChallenge constraint. A provider's widget posts its token under its own name (altcha, cf-turnstile-response), outside the form's fields: the field reads it from the request when the form's own data does not hold it.

Option Default
gateway omnishield.challenge.gateway the configured captcha
action null what the form is for: signed into the token where the provider can, checked back
hostname null the host the widget must have been shown on: a host, true for the request's
nonce null the page's Content-Security-Policy nonce, for the widget's scripts

The errors land on the field:

Code Message (to translate in validators)
PassesChallenge::MISSING_ERROR Please confirm that you are not a robot.
PassesChallenge::FAILED_ERROR The check that you are not a robot did not pass. Please try again.
PassesChallenge::UNREACHABLE_ERROR The check that you are not a robot could not be done just now. Please try again in a moment.

The violation's cause is the Verdict: $error->getCause()->getCause()->reasons.

The constraint works without the form type, on a property of a DTO:

use Omnishield\Bridge\Symfony\Validator\PassesChallenge;

final class ContactRequest
{
    #[PassesChallenge(gateway: 'forms', action: 'contact', hostname: true)]
    public string $captcha = '';
}

The visitor's address is the request's getClientIp(): behind a proxy, set framework.trusted_proxies. A key the provider refuses is thrown (InvalidKeyException): it is the site's error, not the visitor's.

The ALTCHA challenge's route

The ALTCHA widget carries its challenge in the page by default. A page that is cached - by a reverse proxy, by the browser - would serve everyone the same challenge: let the widget fetch a fresh one instead.

// config/routes/omnishield.php
use Omnishield\Bridge\Symfony\Controller\ChallengeController;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

return static fn (RoutingConfigurator $routes) => $routes->import(ChallengeController::class, 'attribute');
omnishield:
    gateways:
        forms: { factory: altcha, options: { hmac_key: '%env(ALTCHA_HMAC_KEY)%', challenge_url: /omnishield/forms/challenge } }

GET /omnishield/{gateway}/challenge?action=contact (omnishield_challenge) answers a fresh challenge, Cache-Control: no-store; 404 for a gateway that issues none. The controller is a plain class: no AbstractController, nothing of FrameworkBundle.

The widget's script, from the site

omnishield/altcha ships its widget's script (public/altcha.min.js, the npm package's file, MIT). The bundle serves it at /omnishield/altcha/3.3.0/altcha.min.js - answered before routing, cached a year, with no route to import and no asset pipeline (ScriptListener) - and makes that the default script of every altcha gateway that names none: the page reaches nobody, Widget::$origins is empty. serve_scripts: false leaves the package's default (jsDelivr); a script option given is the application's.

The widget's language

A gateway whose widget takes texts (LocalizableInterface: ALTCHA) is printed in the request's language, by the form type and by omnishield_widget() alike (WidgetLocalizer): its texts come from the translation domain omnishield, <gateway>.<text> - altcha.label, altcha.verifying, altcha.verified... -, shipped in French, English, German and Japanese (Bridge/Symfony/translations/). An application's own translations/omnishield.<locale>.yaml overrides any text, or adds a language; a language nobody translated leaves the widget's own English. A gateway's language option fixes its language whatever the visitor's, its strings option wins over the catalogues. Without symfony/translation, nothing changes.

Twig

For a form that is not a Symfony form:

<form method="post">
    {# ... #}
    {{ omnishield_widget() }}                          {# the default captcha #}
    {{ omnishield_widget('forms', 'contact') }}        {# a captcha, an action #}
    {{ omnishield_widget('forms', nonce: csp_nonce) }}
</form>

{% set widget = omnishield_widget_data('forms') %}    {# its parts: widget.field, widget.origins, widget.thirdParty #}

A provider's script is printed once per page, however many widgets and fields: with the first printed. A field's omnishield_html is printed when the field is, so a form's view built and thrown away (or a field taken out of the view) leaves the script to the one the page shows.

Turnstile and reCAPTCHA reach a third party (widget.reachesOthers()), and reCAPTCHA sets a cookie: their widget is loaded once the visitor agreed, and a form whose visitor refused needs another captcha - ALTCHA, which reaches nobody. See privacy.

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has an Omnishield provider: Omnibridge\Laravel\Shield\ShieldServiceProvider. OmnibridgeServiceProvider registers it when the class Omnishield\Registry exists; it reads config/omnishield.php, which takes the keys of the Symfony bundle's omnishield.yaml, and wires nothing at boot without a gateway. It was written for omnibase's captcha after failed sign-ins (omniauth's step AU8b), and does less than the bundle. This page describes omnibridge/laravel at its 1.x branch.

// config/omnishield.php (php artisan vendor:publish --tag=omnishield-config)
return [
    'gateways' => [
        'forms' => ['factory' => 'altcha', 'options' => ['hmac_key' => env('ALTCHA_HMAC_KEY')]],
        'emails' => ['factory' => 'disposable'],
    ],
    'challenge' => ['gateway' => null, 'unreachable' => 'accept'],
    'serve_scripts' => true,
    'replay' => ['pool' => null],
];
Key Default
gateways.<name>.factory, .options [] as under Symfony; an entry without a string factory is skipped
challenge.gateway null: the first gateway ShieldServiceProvider::defaultChallenge($app)
challenge.unreachable reject ShieldServiceProvider::acceptsUnreachable($app): only accept accepts
serve_scripts true ALTCHA's script served at /omnishield/altcha/3.3.0/altcha.min.js (route omnishield_altcha_script, cached a year) and made the default script of every altcha gateway that names none
replay.pool null a binding that is a PSR-6 pool: CacheReplayStore on it; otherwise InMemoryReplayStore

challenge.unreachable defaults to reject, as the Symfony bundle's same key: the same configuration refuses a form under either host when a provider does not answer.

What it binds

Binding
Omnishield\Registry the gateways of config/omnishield.php, built by Omnishield\Altcha\AltchaGatewayFactory (given the replay store), Omnishield\Disposable\DisposableGatewayFactory and Omnishield\Testing\FixedGatewayFactory, those installed
Omnishield\Replay\ReplayStoreInterface CacheReplayStore on replay.pool, else InMemoryReplayStore
Omnishield\WidgetPrinter one printer per request (a scoped binding)

Only the factories that need no HTTP client are registered: a gateway configured with turnstile, recaptcha, akismet or stopforumspam is an InvalidConfigException (No "turnstile" factory for the "forms" gateway; installed: altcha, disposable, fixed.) the first time it is asked for. A site that needs one builds its factory itself, with a symfony/http-client client, and binds its own Registry:

// app/Providers/ShieldServiceProvider.php
use Omnishield\Registry;
use Omnishield\Turnstile\TurnstileGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

$this->app->singleton(Registry::class, fn () => new Registry([
    new TurnstileGatewayFactory(HttpClient::create()),
], config('omnishield.gateways')));

The WidgetPrinter is a scoped binding, a new one for each request, as the Symfony bundle resets its own between two (kernel.reset): under a long-running worker (Octane, FrankenPHP) a provider's script printed on one page is printed again on the next.

There is no form field, constraint, challenge route or Twig function under Laravel: a form prints $widget->html() (or WidgetPrinter::print()) and verifies with $registry->challenge($name)->verify(Attempt::fromPost($request->all(), $widget, $request->ip())), as in plain PHP (Installation). With omnibase's stores and omnibase 4.x, omnibridge/laravel's Auth\OmnibaseEffects builds omnibase's FormGuard and SignInCaptcha on this registry, with defaultChallenge() and acceptsUnreachable() (omniauth's Laravel page).

This page was written from omnibridge/laravel's code; it was not run in a Laravel application here.

Proposer une modification docs/laravel.md

Models

All models are readonly; times are DateTimeImmutable. They carry strings and dates - never an account, an entity or a request: the application fills them from its own.

Widget

What the page shows for a captcha (ChallengeInterface::widget($action)).

Property
field the posted field that carries the token: altcha, cf-turnstile-response, g-recaptcha-response
script, scriptAttributes the provider's script and its element's attributes (type, async, defer, integrity, crossorigin)
tag, attributes, content the element the script turns into a widget: altcha-widget, div; true prints an attribute's name alone, false leaves it out
inline a short script of the gateway's package, printed after the element (reCAPTCHA's invisible and score modes)
siteKey the provider's public key for the site, when it has one
action the action it was made for
thirdParty whether the check itself goes through someone other than the site
cookies whether the provider sets or reads cookies in the visitor's browser
origins who the visitor's browser talks to besides the site: https://challenges.cloudflare.com

html(?string $nonce = null, bool $script = true) prints it - the nonce of a Content-Security-Policy on both scripts; reachesOthers() is the question to ask before: thirdParty or any origins. WidgetPrinter prints a provider's script once per page, however many forms, with the first widget printed; later() gives a widget's markup to print when the page shows it (a WidgetHtml, cast to a string, wrap() to put it between two pieces of markup): a view built and never shown takes no script.

Attempt

A token to check (ChallengeInterface::verify()).

Property
token what the widget posted; '' when nothing was
ip the visitor's address, as the application sees it behind its proxies; given to providers that take it
action the action the token must have been made for; null: not checked
hostname the host the widget must have been shown on; null: not checked

Attempt::fromPost($_POST, $widget, $ip, $hostname) reads the token under the widget's field (trimmed; a value that is not a string reads as '') and takes its action; isEmpty() is true for ''.

Verdict

What a captcha said of a token.

Property
passed whether the token holds
score from 0.0 (a bot) to 1.0 (a person), where the provider scores (reCAPTCHA v3, Enterprise)
action, hostname as the provider read them
at when the challenge was solved, or issued (ALTCHA)
reasons why it did not pass, in the family's words: missing, invalid, expired, duplicate, spent (expired or used: the provider does not say which), action, hostname, score
codes the provider's own error codes or reasons: timeout-or-duplicate, DUPE, AUTOMATION

Verdict::fail($reason, $codes), failedFor($reason), toArray() (at as an ATOM date). The reasons are its constants: Verdict::MISSING, INVALID, EXPIRED, DUPLICATE, SPENT, ACTION, HOSTNAME, SCORE; a verdict passes exactly when it has none (the conformance suite checks it of every gateway).

Submission

What a visitor submitted, as a classifier reads it.

Property
content what was written
author, email, url the name, the e-mail and the address the author gave - as text
ip, userAgent, referrer from the request the content came with
permalink the page it was submitted on, or will be shown at
site the site's home page: https://example.org - not the current page
language the site's language: fr, fr_FR
type comment, reply, forum-post, blog-post, contact-form, signup, message (the constants of Submission)
date when it was submitted

identity() gives the submitter as a list reads them.

Classification

Property
label Label::HAM, Label::SPAM (hold it for a person), Label::FLAGRANT (spam beyond doubt: it may be dropped unseen)
reasons what the provider gave as reasons or alerts, in its own words
data what else it answered: a delay to ask again in

isSpam() (spam or flagrant), isFlagrant(), Classification::ham().

Label is a string enum: HAM (ham), SPAM (spam), FLAGRANT (flagrant).

Identity

ip, email, name - any may be missing. domain() gives the e-mail's domain (after its last @), lower case, without a trailing dot, or null; isEmpty() is true when all three are null or empty (a list then answers unknown without asking).

Reputation

What a list knows of an identity.

Property
known whether the list holds it for abusive, at the gateway's threshold
frequency how many times it was reported, where the list counts
confidence from 0 to 100: how surely it is abusive, as the list reckons
lastSeen when it was last reported
reasons which parts matched: ip, email, name, and disposable, tor
data what the list answered, part by part

matched($part), Reputation::unknown($data = []), toArray(). The parts are its constants: Reputation::IP, EMAIL, NAME, DISPOSABLE, TOR.

Capabilities

What a gateway is, before it is asked anything ($gateway->capabilities()).

Property
challenge, classifier, reputation which of the three questions it answers; questions() lists them
thirdParty, cookies through whom, with what
scores whether a Verdict carries a score
actions whether it checks the action a token was made for
reports whether it takes reports back
reads what of an Identity a list reads: ip, email, name

questions() lists challenge, classifier, reputation as claimed; toArray() gives them with every property. Defaults: every question false, thirdParty true, everything else false or empty.

Answer

Omnishield\Http\Answer is the one place the gateways call a provider through: Answer::send($http, $provider, $method, $url, $options) (a 10-second timeout unless the options give one) returns the status, the headers (lower-case names) and the body; no connection, a timeout, HTTP 5xx and 429 are an UnreachableException naming the provider. header($name) reads one header, json() the body as a JSON object (anything else: a ProviderException). A gateway of your own calls through it too (Writing a gateway).

Proposer une modification docs/models.md

The three questions

A gateway answers one question - its Capabilities say which - and the Registry gives it as the contract that asks it: challenge($name), classifier($name), reputation($name), or a NotSupportedException naming what it does answer.

Contract The question Methods
ChallengeInterface is this token valid? widget(?string $action): Widget, verify(Attempt): Verdict
ChallengeIssuerInterface the same, for a captcha whose challenge the site issues issue(?string $action): array besides
ClassifierInterface is this content spam? classify(Submission): Classification, report(Submission, bool $spam)
ReputationInterface are this address, this e-mail, this name known for abuse? lookup(Identity): Reputation
altcha turnstile recaptcha akismet stopforumspam disposable
Answers challenge (issuer) challenge challenge classifier reputation reputation
Called nothing siteverify siteverify, Enterprise assessments comment-check, submit-spam, submit-ham, verify-key api, add (reports) nothing
Third party none Cloudflare Google Akismet StopForumSpam none
Cookies none none _GRECAPTCHA - - -
Score no no v3, Enterprise - confidence 100 when listed
Action checked yes, signed in the challenge yes v3, Enterprise - - -
Spent tokens the site's store Cloudflare refuses them Google refuses them - - -
Needs a key of the site's site key, secret site key, secret; Enterprise: project, API key an API key nothing; a key to report nothing

A captcha

$captcha = $registry->challenge('forms');
$widget = $captcha->widget('contact');

// The page
echo $widget->html($cspNonce);

// The handler
$verdict = $captcha->verify(Attempt::fromPost($_POST, $widget, $clientIp, 'www.example.org'));
if (!$verdict->passed) {
    // $verdict->reasons: missing, invalid, expired, duplicate, spent, action, hostname, score
}
  • An empty token is never sent: it fails as missing at once (Google's test secret would pass it).
  • The action is what the form is for - contact, signup. ALTCHA signs it into the challenge, Turnstile and reCAPTCHA v3 carry it in the token: the gateway checks it back when the attempt names one. Keep it a short word of letters, digits and underscores (reCAPTCHA's rule).
  • The host is checked when the attempt names one; the providers' testing keys answer for any host (example.com, testkey.google.com), so it is not checked against theirs.
  • A token serves once. Turnstile and reCAPTCHA refuse a second use on their side (spent); ALTCHA cannot, and the gateway's store does (duplicate).

A captcha the site issues itself (ChallengeIssuerInterface: ALTCHA) carries a fresh challenge in its widget, or the address it is fetched from when challenge_url is set: issue() is what that address answers - the Symfony bridge has the route.

A classifier

$classification = $registry->classifier('comments')->classify(new Submission(
    content: $comment->text, author: $comment->name, email: $comment->email, url: $comment->website,
    ip: $request->ip, userAgent: $request->userAgent, referrer: $request->referer,
    permalink: 'https://example.org/blog/a-post', site: 'https://example.org', language: 'fr',
    type: Submission::COMMENT, date: $comment->postedAt,
));

match ($classification->label) {
    Label::HAM => $comment->publish(),
    Label::SPAM => $comment->holdForModeration(),
    Label::FLAGRANT => $comment->discard(),
};

// later, a moderator corrects it
$registry->classifier('comments')->report($submission, spam: true);

Send the submission as it was classified: the classifier finds it by what it was told.

A list

$reputation = $registry->reputation('reported')->lookup(new Identity($ip, $email, $name));
$reputation->known;                // at the gateway's threshold
$reputation->reasons;              // ['ip', 'tor'], ['email', 'disposable']
$reputation->confidence;           // 0-100

A list reads the parts its Capabilities::$reads name and ignores the others; it is asked about the people who submit a form, never about every visitor.

Writing a gateway

A package omnishield/<provider>: a factory extending Omnishield\GatewayFactory that fills a Config - its name, title, the options it needs and their defaults - and builds the gateway, a class implementing the contract of its question:

final class HcaptchaGatewayFactory extends GatewayFactory
{
    // A provider reached over HTTP takes its client here: the application's, a MockHttpClient in a test.
    public function __construct(private readonly ?HttpClientInterface $http = null)
    {
    }

    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnishield.factory_name' => 'hcaptcha',
            'omnishield.factory_title' => 'hCaptcha',
            'omnishield.required_options' => ['site_key', 'secret'],
        ]);
    }

    protected function build(Config $c): GatewayInterface
    {
        return new HcaptchaGateway($this->http ?? HttpClient::create(), (string) $c['site_key'], (string) $c['secret']);
    }
}

Call the provider through Omnishield\Http\Answer::send(): no answer, a server error or a quota exceeded become an UnreachableException, and the rest comes back for the gateway to read. Turn a refusal into a Verdict, a refused key into an InvalidKeyException, and leave out what the provider's documentation does not show. An HTTP 401 or 403 is the provider refusing the site's credentials: an InvalidKeyException when the gateway sends a key or a secret, a ProviderException when it sends none (StopForumSpam's lookup), whatever the body says. Test it on MockHttpClient with answers recorded from the provider (Tests/Fixtures/*.json), and say in its documentation what was verified for real and what was not.

Conformance suite

Omnishield\Testing\GatewayContractTests is the family's contract as PHPUnit tests: every gateway package runs it, offline, in a Tests/<Name>ContractTest.php of its own. The test case gives the factory (handed a MockHttpClient: no test reaches the network), the options that build a working gateway, and recorded cases - an input, whether the gateway accepts it, the provider's recorded answers, options of the case's own:

use Omnishield\Testing\GatewayContractTests;

final class HcaptchaContractTest extends TestCase
{
    use GatewayContractTests;

    protected function factory(HttpClientInterface $http): GatewayFactoryInterface
    {
        return new HcaptchaGatewayFactory($http);
    }

    protected function options(): array
    {
        return ['site_key' => '10000000-ffff-ffff-ffff-000000000001', 'secret' => '0x0000000000000000000000000000000000000000'];
    }

    protected function recordedCases(): iterable
    {
        yield 'passes' => [new Attempt('10000000-aaaa-bbbb-cccc-000000000001'), true, [new MockResponse(file_get_contents(__DIR__.'/Fixtures/siteverify-passes.json'))]];
        yield 'spent' => [new Attempt('10000000-aaaa-bbbb-cccc-000000000001'), false, [new MockResponse(file_get_contents(__DIR__.'/Fixtures/siteverify-spent.json'))]];
    }
}

An input is an Attempt (a captcha), a Submission (a classifier) or an Identity (a list), or a closure that makes one from the gateway (an ALTCHA token solved from its issue()). Each question the gateway's Capabilities claim needs a case it accepts and one it refuses. What it checks:

Factory and registry the factory's name is the gateway's; the Registry builds it from {factory, options}, once
Options each required option left out, or empty, is an InvalidConfigException naming it
Capabilities each question claimed is its interface, and only those; the Registry refuses the others with a NotSupportedException; reads only for a list; actions and scores only for a captcha; a classifier without reports throws NotSupportedException on report()
Nothing for nothing an empty token is a Verdict missing, an empty Identity unknown, without a request; a gateway that says nothing leaves the site (thirdParty: false) makes no HTTP request at all
A captcha its widget: a field, thirdParty and cookies as the Capabilities say, the action asked for; an issuer issues a fresh JSON challenge each time; a localizable one stays itself
Invariants a Verdict passes exactly when it has no reason, its reasons are the family's constants, a score lies in 0..1 and only where the gateway scores; a Classification has a Label; a known Reputation says which parts matched (among those it reads, or disposable, tor), an unknown one none, its confidence in 0..100
Recorded cases answered as recorded; every recorded answer read, no more asked for
Errors (third party) no answer, HTTP 5xx and 429: UnreachableException; HTTP 401 and 403: InvalidKeyException (ProviderException for a gateway that sends no key: override sendsCredentials()); an answer it cannot read: ProviderException - each naming the provider, never a TypeError or a PHP warning

Hooks with defaults: requiredOptions() (what a GatewayFactory says it requires), sendsCredentials() (whether any option is required), sampleSubmission(), sampleIdentity(). It needs phpunit/phpunit and symfony/http-client in the package's require-dev.

Proposer une modification docs/gateways.md

Privacy

A guard looks at a visitor - their address, what they wrote, how their browser behaves. Where that goes depends on the gateway, and so do what the site's privacy policy says and whether the visitor is asked first.

Gateway What leaves the site To whom Cookies Widget::origins
altcha nothing: the challenge is issued and checked by the site - none none in Symfony: the bridge serves the widget's script from the site; the CDN (https://cdn.jsdelivr.net) in PHP alone, unless the site serves the package's copy
turnstile the visitor's browser talks to Cloudflare (its address, browser signals); the site sends the token and the address to siteverify Cloudflare none: "strictly necessary" signals, by Cloudflare's Turnstile privacy addendum (2025-06-18) https://challenges.cloudflare.com
recaptcha the visitor's browser talks to Google; the site sends the token and the address Google _GRECAPTCHA, "a necessary cookie" by Google's FAQ; www.recaptcha.net instead of www.google.com keeps google.com's other cookies away https://www.google.com (or https://www.recaptcha.net), https://www.gstatic.com
akismet the text, the name, the e-mail, the address, the browser, the pages Akismet (Automattic) - -
stopforumspam the address, the e-mail (hashed: MD5 by default), the name - what send lists StopForumSpam - -
disposable nothing: the list is a file of the package - - -

What the CNIL says of captchas

The CNIL's questions and answers on cookies and other trackers (question 17, "Le consentement des utilisateurs doit-il être recueilli pour l'utilisation de systèmes anti-robot ou « CAPTCHA » ?", page updated on 2026-04-29, www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/FAQ) draws the line by purpose:

  • no consent is needed when the solution's sole purpose is to secure the site or an authentication mechanism ("ont pour seule finalité la sécurisation du site ou d'un mécanisme d'authentification");
  • consent is needed when it serves other purposes, not strictly necessary - when the provider may reuse the data on its own account ("réutiliser les données pour leur propre compte");
  • it advises asking the provider how it uses the data, and using alternatives that need no consent - since a captcha that waits for consent is skipped by whoever refuses.

What follows for a site built on Omnishield - read as the CNIL's answer, not as legal advice:

  • ALTCHA reaches nobody and sets nothing: no consent to ask, nothing to declare but the check itself. Its script is the site's own - served by the Symfony bridge from omnishield/altcha, or copied from it in PHP alone - and not even a CDN sees the visitor. It is the default for that reason.
  • Turnstile reaches Cloudflare: name it in the privacy policy; Cloudflare says it uses the signals only to tell people from bots. Whether that is "sole purpose" enough is the site's call, with its DPO.
  • reCAPTCHA reaches Google and sets a cookie; what Google does with the data beyond the check is the question the CNIL tells the site to ask its provider. Load it after consent. When the visitor refuses, the form must still be guarded - by ALTCHA: a refusal must not open the form.
  • Akismet and StopForumSpam receive the visitor's data from the site's server: name them, say what is sent and why (protecting the forms), and keep StopForumSpam off unless the site wants it - its address goes to a third party at each check.
$widget = $registry->challenge('forms')->widget('contact');
if ($widget->reachesOthers()) {
    // name $widget->origins to the visitor, wait for their consent, then print $widget->html()
}

The origins also go in the page's Content-Security-Policy (script-src, frame-src, connect-src).

Proposer une modification docs/privacy.md

Configuration reference

The registry's configuration

new Registry($factories, $config), the bundle's omnishield.gateways (Symfony) and config/omnishield.php's gateways (Laravel) take the same shape: gateway name => factory and options.

[
    '<gateway name>' => [
        'factory' => '<factory name>',     // required: a factory's getName()
        'options' => [/* ... */],          // optional, default []: handed to the factory's create()
    ],
]

The gateway name is the site's (forms, comments, emails); the factory name the package's (altcha, akismet, disposable). Several gateways may use the same factory with different options (two Akismet sites, a strict and a lenient StopForumSpam).

Registry method
get($name) the gateway, built once from its factory and options
challenge($name), classifier($name), reputation($name) the gateway as that contract, or a NotSupportedException naming what it answers
has($name), names(), options($name), all() the configured gateways
factories() the factories installed: what factory may name
create($name, $overrides) a gateway built afresh, its options with $overrides over them (a key typed in a back office); not kept

An unknown gateway, or a factory not installed, is an InvalidConfigException naming what is configured or installed.

How options are read

A factory fills a Config (an ArrayObject): the options given, its defaults under them (defaults() sets a key only when it is not set), and its required keys, checked by validateNotEmpty() - missing, '' or [] is an InvalidConfigException: The "altcha" gateway needs: hmac_key. The helpers the factories read options with, so that an environment variable works as well as a typed value:

Helper Reads
get($key, $default) the value as it is
string($key) a string; null when empty
bool($key) a bool, or "1", "true", "yes", "on" (any case) as true
list($key) a list, or one comma-separated string (ip,email), trimmed, empty items dropped

Reserved keys start with omnishield.: omnishield.factory_name, omnishield.factory_title, omnishield.required_options. Given in the options they win over the factory's (a required option made optional, say) - a way to adapt one gateway without writing a factory.

Every provider's options

Each provider's manual explains its options; the table lists them as each factory declares them, with their defaults.

Factory Required Optional (default)
altcha hmac_key algorithm (PBKDF2/SHA-256), cost (5000), key_prefix (00), expires (600), challenge_url (null), field (altcha), script (jsDelivr's altcha@3.3.0; under the bridges the site's copy), integrity (null: set for the two known addresses), attributes ([]), configuration ([]), language (null), strings ([])
turnstile site_key, secret theme, size, appearance, language (null), attributes ([]), field (cf-turnstile-response), idempotency (true), base_uri (https://challenges.cloudflare.com/turnstile/v0/)
recaptcha site_key; secret (checkbox, invisible, score) or project_id and api_key (enterprise) mode (checkbox), threshold (0.5), default_action (submit), domain (www.google.com), language, theme, size, badge (null), attributes ([])
akismet api_key site (null), language (null), test (false), base_uri (https://rest.akismet.com/1.1/)
stopforumspam - threshold (50), send ([ip, email, name]), hash_email (true), expire (null), tor (neutral), wildcards (true), api_key (null), base_uri (https://api.stopforumspam.org/api), report_uri (https://www.stopforumspam.com/add)
disposable - list (null: the shipped file), allow ([]), deny ([])
fixed - pass (true)

A factory's constructor takes what its gateway works with, not options: an HTTP client for those that call a provider (?HttpClientInterface $http, HttpClient::create() when none is given), the store of spent tokens for ALTCHA (?ReplayStoreInterface $replays, InMemoryReplayStore when none is given).

The store of spent tokens

Class Constructor
Replay\InMemoryReplayStore (?\Closure $clock = null): a closure giving the time, time() otherwise this process's memory; count() the tokens kept
Replay\CacheReplayStore (CacheItemPoolInterface $pool, string $prefix = 'omnishield.spent.') a PSR-6 pool, each token's id hashed (SHA-256) under the prefix, kept until it would have lapsed

ReplayStoreInterface::spend($id, $until) is true the first time, false after; an implementation of the site's on an atomic store (a unique key, Redis's SET NX) is exact under concurrent requests, where a PSR-6 pool (read, then written) is not.

Proposer une modification docs/configuration.md

Errors

A visitor who fails a captcha, a comment that is spam, an address that is listed: none of them is an exception. They are answers - a Verdict that did not pass, a Classification labelled spam, a known Reputation. Exceptions are for what decided nothing: a provider that did not answer, a key it refused, a configuration to fix.

The exceptions

Every exception Omnishield throws implements Omnishield\Exception\OmnishieldException (a \Throwable):

Exception Extends When
UnreachableException ProviderException the provider did not answer: no connection, a timeout, a server error (HTTP 5xx), a quota exceeded (HTTP 429), Turnstile's internal-error. Nothing was decided
InvalidKeyException ProviderException the provider refused the site's key or secret: HTTP 401 or 403 from a gateway that sends one, missing-input-secret / invalid-input-secret, Akismet's invalid about the key, Enterprise's PERMISSION_DENIED. The configuration is wrong, whatever the visitor did
ProviderException \RuntimeException the provider answered with an error rather than a verdict: bad-request, an answer that is not JSON, StopForumSpam's success: 0, an Akismet request it could not read. $provider names it (turnstile), $providerCode keeps its own code; the message starts with [<provider>]
InvalidConfigException \LogicException a gateway not configured, a factory not installed, a required option missing or empty, an option out of its range (a reCAPTCHA mode, a threshold, an ALTCHA algorithm or key prefix, StopForumSpam's send or tor), a list file unreadable, a submission without what the provider needs (Akismet: the visitor's IP and the site's home page; a StopForumSpam report: all three parts)
NotSupportedException \LogicException the gateway does not answer that question (Registry::challenge() of a list), or does not take reports (StopForumSpam without api_key)

The HTTP statuses map the same way in every gateway, and the conformance suite checks it of each one: no answer, 5xx and 429 an UnreachableException; 401 and 403 an InvalidKeyException (a ProviderException for a gateway that sends no key: StopForumSpam's lookup); an answer it cannot read a ProviderException - never a TypeError or a PHP warning.

These, as the core and a factory throw them (run in the workspace on 2026-10-26, offline):

NotSupportedException: The "emails" gateway (Disposable e-mail domains) does not answer "is this content spam?"; it answers: reputation.
InvalidConfigException: No "comments" gateway; configured: forms, strict, emails.
InvalidConfigException: No "akismet" factory for the "comments" gateway; installed: none.
InvalidConfigException: The "altcha" gateway needs: hmac_key.

Outside the family's exceptions: DisposableList throws a \RuntimeException when it cannot read its file or write a refreshed one; the Symfony bridge's validator a \LogicException when no captcha is named and omnishield.challenge.gateway is unset (as does omnishield_widget()), and Symfony's UnexpectedValueException for a value that is not a string; ChallengeController a 404 for a gateway that issues no challenge.

What to catch where

Where Catch
a form's handler, verifying a captcha UnreachableException: refuse the form, or let it through and look later - the site's call (the bridge's challenge.unreachable). InvalidKeyException is the site's error: let it surface, alert someone
a comment classified UnreachableException: hold the comment for moderation rather than publish it
a list asked at sign-up UnreachableException: go on without its answer
building the registry, a deploy InvalidConfigException: a configuration to fix, not to retry
a feature a gateway may lack read capabilities() first; NotSupportedException is a programming error

The bridge's PassesChallenge constraint does this for a captcha: a provider that does not answer is the violation UNREACHABLE_ERROR (or nothing, with unreachable: accept), a refused key is thrown (Symfony).

No secret in a failure

Answer::send() keeps the request's secrets out of what it throws. The HTTP client's message quotes the URL called: its key parameters, a password in its user part and every secret of the request's options are masked (api_key=***, Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message, its own previous copied the same way), and no trace of the chain keeps its arguments, where the query, headers and body stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key.

Proposer une modification docs/errors.md

Testing

Testing an application that guards its forms with Omnishield. Writing a gateway and its conformance suite: Writing a gateway.

The fixed gateway

Omnishield\Testing\FixedGateway always says the same: it passes every token, every submission and every identity, or refuses them all - and it answers the three questions, so one configuration stands in for any gateway. Its factory is fixed (Testing\FixedGatewayFactory, option pass, default true), always registered by the Symfony bundle and by omnibridge/laravel.

# config/packages/test/omnishield.yaml, or when@test
omnishield:
    gateways:
        forms: { factory: fixed }                    # the site's captcha, in tests
        comments: { factory: fixed, options: { pass: false } }   # every comment spam
pass: true pass: false
widget($action) <input type="hidden" name="omnishield-token" value="omnishield-fixed-token"> (FixedGateway::FIELD, TOKEN) the same
verify() passed (score 1.0); an empty token missing refused invalid (score 0.0); an empty token missing
classify() Classification::ham() Label::SPAM, reason fixed
report() kept in $reports (the submission, $spam) for the test to read the same
lookup() unknown known, frequency 1, confidence 100, the parts given

Its widget is a hidden field already holding a token: a form submitted by a test client (Symfony's KernelBrowser, Laravel's HTTP tests) carries it as a browser would, and a test can still check that a form posted without it is turned away. FixedGateway::passing() and failing() build one by hand.

<?php

require 'vendor/autoload.php';

use Omnishield\Disposable\DisposableGatewayFactory;
use Omnishield\Model\Attempt;
use Omnishield\Model\Identity;
use Omnishield\Model\Submission;
use Omnishield\Registry;
use Omnishield\Testing\FixedGatewayFactory;

$registry = new Registry([new FixedGatewayFactory(), new DisposableGatewayFactory()], [
    'forms' => ['factory' => 'fixed'],                              // passes every token but an empty one
    'strict' => ['factory' => 'fixed', 'options' => ['pass' => 'false']],   // an environment variable's "false"
    'emails' => ['factory' => 'disposable'],
]);

$widget = $registry->challenge('forms')->widget('contact');
echo $widget->html(), "\n";
$post = [$widget->field => 'omnishield-fixed-token'];
echo 'forms: ', $registry->challenge('forms')->verify(Attempt::fromPost($post, $widget))->passed ? 'passed' : 'refused', "\n";
echo 'forms, nothing posted: ', implode(', ', $registry->challenge('forms')->verify(Attempt::fromPost([], $widget))->reasons), "\n";
echo 'strict: ', implode(', ', $registry->challenge('strict')->verify(Attempt::fromPost($post, $widget))->reasons), "\n";
echo 'strict, a comment: ', $registry->classifier('strict')->classify(new Submission('Hello'))->label->value, "\n";
echo 'strict, a visitor: ', implode(', ', $registry->reputation('strict')->lookup(new Identity('192.0.2.10', 'a@example.org'))->reasons), "\n";
echo 'names: ', implode(', ', $registry->names()), '; factories: ', implode(', ', $registry->factories()), "\n";
<input type="hidden" name="omnishield-token" value="omnishield-fixed-token" data-omnishield-action="contact">
forms: passed
forms, nothing posted: missing
strict: invalid
strict, a comment: spam
strict, a visitor: ip, email
names: forms, strict, emails; factories: fixed, disposable

(run in the workspace on 2026-10-26, offline; pass given as the string "false", as an environment variable would give it)

A provider on recorded answers

To test the application's handling of a real provider's answers - and of its failures - give the provider's factory a MockHttpClient (symfony/http-client): each gateway that calls a provider takes its HTTP client in its factory's constructor.

use Omnishield\Exception\UnreachableException;
use Omnishield\Model\Attempt;
use Omnishield\Turnstile\TurnstileGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$turnstile = (new TurnstileGatewayFactory(new MockHttpClient([
    new MockResponse('', ['http_code' => 503]),
    new MockResponse('', ['http_code' => 503]),     // the retry under the same idempotency key
])))->create(['site_key' => '1x00000000000000000000AA', 'secret' => '1x0000000000000000000000000000000AA']);

$this->expectException(UnreachableException::class);
$turnstile->verify(new Attempt('XXXX.DUMMY.TOKEN.XXXX'));

Each provider package keeps its recorded answers in Tests/Fixtures/ (JSON bodies, with their headers where they matter): a site's tests can read the same shapes. ALTCHA calls nothing - solve a challenge with altcha-org/altcha's own solveChallenge(), as the installation script does - and the disposable list reads a file (point list at a small file of the test's).

Spent tokens

InMemoryReplayStore takes a clock (fn () => $now), so a test moves time rather than waiting; count() says how many tokens it keeps. In a Symfony functional test the store is the service Omnishield\Replay\ReplayStoreInterface.

The conformance suite

Omnishield\Testing\GatewayContractTests is the family's contract as PHPUnit tests, run by every gateway package and by Tests/FixedGatewayContractTest.php here: Conformance suite lists what it checks and its hooks.

Proposer une modification docs/testing.md

The Docker harness

docker/ runs this package with every omnishield/* gateway installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNISHIELD_PLUGINS=../.. is set in docker/.env - with a console and a script in bare PHP.

cd docker && cp .env.dist .env       # the keys you have; none is needed
docker compose run --rm omnishield gateways
Command
gateways the gateways installed and configured, what each answers, through whom, with what
widget <gateway> what the page shows for a captcha: its parts (JSON) and its markup (--action)
solve <gateway> ALTCHA: a challenge issued and solved here, as the widget would: the token to post (--action)
verify <gateway> <token> whether a token holds (JSON): --ip, --action, --hostname; exits 1 when refused
classify <gateway> <text> whether a text is spam (JSON): --author, --email, --ip, --type; --report spam|ham teaches instead
lookup <gateway> what a list knows (JSON): --ip, --email, --name
bare plain PHP: the registry built by hand, each gateway asked a whole question, what PHP loaded
test every package's tests

The console calls the providers for real; --recorded answers from the packages' recorded answers instead. A gateway whose settings are not in .env runs on its example ones: the providers' own testing keys - Cloudflare's dummy keys, Google's v2 test keys -, a fixed key for ALTCHA, and for Akismet a key only the recorded answers accept.

$ docker compose run --rm omnishield verify turnstile XXXX.DUMMY.TOKEN.XXXX --ip 192.0.2.10
{
    "at": "2026-10-07T00:55:07+00:00",
    "passed": true,
    "score": null,
    "action": null,
    "hostname": "example.com",
    "reasons": [],
    "codes": [
        "result_with_testing_key"
    ]
}

$ T=$(docker compose run --rm -T omnishield solve altcha --action contact)
$ docker compose run --rm omnishield verify altcha "$T" --action contact     # passes: each run has its own memory of spent tokens

Bare: no bundle, no container

The console is a symfony/console application over a registry built by hand; bare is less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing else. It builds the Registry from the gateway packages installed, asks each what it answers, then a whole question of each, and lists what PHP loaded - exit 1 if a class of a framework is among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, Form, Validator, Routing, a bundle or a bridge, Doctrine, Twig):

$ docker compose run --rm omnishield bare --live
Omnishield in bare PHP: the registry built by hand, no bundle, no container; the providers themselves.

  altcha         ALTCHA: answers challenge; third party no, cookies no (example settings)
  turnstile      Cloudflare Turnstile: answers challenge; third party yes, cookies no (example settings)
  recaptcha      Google reCAPTCHA v2 (checkbox): answers challenge; third party yes, cookies yes (example settings)
  akismet        Akismet: answers classifier; third party yes, cookies no (example settings)
  stopforumspam  StopForumSpam: answers reputation; third party yes, cookies no
  disposable     Disposable e-mail domains: answers reputation; third party no, cookies no
  fixed          Fixed: passes everything: answers challenge, classifier, reputation; third party no, cookies no

altcha: a challenge issued, solved here in 0.171s
  first post: passed; the same again: refused (duplicate); issued for another action: refused (action)
  the widget: <altcha-widget> posting "altcha", third party no, scripts from https://cdn.jsdelivr.net

turnstile, Cloudflare's testing keys:
  1x: passed; 2x: refused (invalid; invalid-input-response); 3x: refused (spent; timeout-or-duplicate)

recaptcha, Google's v2 test keys: passed from testkey.google.com; an empty token: refused (missing) (nothing sent)

akismet: skipped, no AKISMET_API_KEY: the recorded answers only accept the example key

stopforumspam: 185.220.101.1 known (frequency 91, confidence 95.29, ip, tor)
  91.186.18.61 + g2fsehis5e@mail.ru (hashed): unknown (frequency 0, confidence 0)

disposable: someone@mailinator.com disposable (mailinator.com); someone@eu.yopmail.com disposable (yopmail.com); someone@gmail.com not on the list

fixed: passed

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, Form, Validator, Routing, a bundle or bridge, Doctrine, Twig): none

(2026-10-07)

Without --live the same runs on the recorded answers: nothing leaves the machine. bare --json prints the same whole, every class and file loaded: Tests/BareTest.php runs it in a process of its own and checks the list - of Symfony, only the HTTP client the providers are called through.

The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness volume of the omnishield-harness project. The gateways are cloned from GitHub as plain git repositories over HTTPS: no GitHub API, no ssh in the image.

The workspace, without the harness

The packages also sit side by side in a Composer workspace (~/Sites/omnishield: a composer.json whose path repositories link core and the six providers into one vendor/, and a phpunit.xml.dist with a test suite per package). The examples of this manual were run there on 2026-10-26, offline, in a PHP 8.4 container with the workspace mounted read-only and no network (docker run --rm --network none -v <workspace>:/ws:ro ... php /scratch/example.php, the autoloader /ws/vendor/autoload.php): the harness's image, omnisense/omnishield-harness, was not built on that machine. The workspace's suite ran there too: 159 tests, 1231 assertions, green.

The harness's service uses Compose's default network: docker compose down -v removes it with the harness volume once done.

Proposer une modification docs/harness.md

Verified, and not

What the tests prove

Run on 2026-10-26 in the workspace (every package from its checkout, PHP 8.4, PHPUnit 11.5, offline): 159 tests, 1231 assertions, green. Each of the six gateways runs the conformance suite (Omnishield\Testing\GatewayContractTests, Conformance suite) in Tests/<Name>ContractTest.php, on recorded answers, and so does Testing\FixedGateway (Tests/FixedGatewayContractTest.php):

  • the factory and the registry, the required options;
  • each question claimed by the Capabilities answered through its interface, and only those;
  • nothing asked for nothing: an empty token missing, an empty identity unknown, without a request; a gateway that says it reaches nobody making no HTTP request at all;
  • the models' invariants (a verdict passes exactly when it has no reason, the reasons the family's, a score in 0..1 where the gateway scores, a known reputation saying which parts matched);
  • every failure of the provider (no answer, 5xx, 429, 401, 403, an unreadable answer) the right exception naming the provider.

The core's own tests cover the registry, the models, the HTTP answer, the replay stores, the Symfony bundle (Tests/Bridge/OmnishieldBundleTest.php), the form field and its constraint (Tests/Bridge/ChallengeFormTest.php), the widget's language (Tests/Bridge/WidgetLocalizerTest.php) and the plain-PHP run with no framework class loaded (Tests/BareTest.php).

The examples of this manual were run in the workspace on 2026-10-26, offline, the providers' answers given by a MockHttpClient, and their output pasted as printed: the fixed gateway, the registry's errors, the bundle's configuration tree, and each provider's example. The installation page's script is the exception: its output is the one of 2026-10-07, when Cloudflare and StopForumSpam were called for real.

Against the providers

Each provider's manual says what was verified for real. In short (from their manuals): Turnstile's three testing keys and reCAPTCHA v2's test keys against siteverify, and StopForumSpam's lookups, called for real on 2026-10-07; ALTCHA's whole way in PHP, and its script compared with npm's; Akismet, reCAPTCHA v3 and Enterprise, and StopForumSpam's reports not, for want of a key; no widget run in a browser.

What is not verified

  • A widget in a browser: the markup follows each provider's documentation and README.
  • CacheReplayStore under concurrent requests (a PSR-6 pool has no atomic add).
  • The Laravel page was written from omnibridge/laravel's code, not run in a Laravel application.

Responsibility

omnisense/omnishield and its omnishield/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by the services it reaches. Each service keeps its own terms (accounts and keys, rate limits, commercial or non-commercial use, what becomes of the visitors' data), named with a link in the "Responsibility" section of the README of each provider that reaches one. The MIT licence covers this code only and grants no right to any service or to its data; whoever runs these packages complies with those terms.

Proposer une modification docs/verified.md