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 itsCapabilitiessay 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 | - | - | - | 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/omnishieldrequiressymfony/http-client-contracts(^3.0) and nothing else.omnishield/turnstile,omnishield/recaptcha,omnishield/akismetandomnishield/stopforumspamrequiresymfony/http-client(6.4, 7 or 8);omnishield/altcharequiresaltcha-org/altcha(^2.3) and no HTTP client;omnishield/disposablerequires nothing more.- Suggested, for what uses them:
psr/cache(CacheReplayStore),phpunit/phpunitandsymfony/http-client(the conformance suite), and for the Symfony bridgesymfony/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
- 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). - 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. - 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. - 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\OmnishieldBundleregisters the factories, builds the registry fromconfig/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\ShieldServiceProviderbuilds the registry fromconfig/omnishield.php(the same keys) with the gateways that call no provider: see Laravel. - Any other: build the
Registryonce, 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.
Consent
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 | 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
missingat 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 | _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.
Asking for consent with Omnishield's widgets
$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
Capabilitiesanswered 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.
CacheReplayStoreunder 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.