Add a Security Gate
Every form submission passes through an ordered chain of security gates before the email goes out. Three gates ship today, and they run in a fixed order: form key → honeypot → CAPTCHA. Each gate inspects the request and returns a verdict; the first gate to reject stops the submission. This guide covers both extension paths: swapping the shipped reCAPTCHA gate for an hCaptcha / Turnstile provider, and appending an extra gate of your own without disturbing the shipped three.
By the end you will understand the gate contract, know how to return the verdict the controller expects, and be able to either replace the CAPTCHA provider with a one-line preference or slot an additional gate into the chain at the position you choose.
Prerequisites
You should be comfortable with Magento DI (di.xml preferences and array arguments).
A gate is a plain PHP class wired through DI; no Hyvä CMS component work is involved.
The Form Builder ships as hyva-themes/commerce-module-form-builder; your module should
depend on it. See the architecture overview for where the guard sits in the
request flow.
How the Gate Chain Works
The gates are composed in di.xml through SecurityGuard's gates argument, an ordered
array. SecurityGuard walks the array in order and asks each gate for a verdict. Because
the list is ordered, position controls when your check runs relative to the shipped
form-key, honeypot and CAPTCHA gates.
There are two interfaces. Hyva\FormBuilder\Model\Security\GateInterface is the general
contract every gate implements. CaptchaGateInterface is a specialization for CAPTCHA
providers. The shipped reCAPTCHA gate is bound as a preference for that interface, which is
what makes swapping the CAPTCHA provider a one-line change.
Step 1: Implement the Gate Interface
Decide first which interface you need. If you are replacing the CAPTCHA provider,
implement CaptchaGateInterface. If you are adding a separate check (an allowlist, a
signed-token check), implement the plain GateInterface.
The worked example below is an hCaptcha provider, so it implements the CAPTCHA
specialization. A gate's single method is
check(RequestInterface $request, FormInterface $form): Verdict. It reads what it needs off
the request and the form, and returns a Verdict.
Whether CAPTCHA is on for this form is an entity-level setting, so read it through
FormSubmissionSettingsReader rather than from the published component metadata. The file
header, namespace and use block follow the shipped gates -
module-form-builder/src/Model/Security/Gate/MagentoReCaptchaGate.php is the full-file
reference - so the example shows the class body:
class HCaptchaGate implements CaptchaGateInterface
{
public function __construct(
private readonly FormSubmissionSettingsReader $settingsReader
) {
}
public function check(RequestInterface $request, FormInterface $form): Verdict
{
if (!$this->settingsReader->fromForm($form)->isCaptchaEnabled()) {
return Verdict::pass();
}
$token = (string)$request->getParam('h-captcha-response', '');
if ($token === '' || !$this->verifyWithHCaptcha($token)) {
return Verdict::rejectCaptcha();
}
return Verdict::pass();
}
private function verifyWithHCaptcha(string $token): bool
{
// POST the token to https://hcaptcha.com/siteverify with your secret, return the result.
return true;
}
}
Read the three shipped gates in module-form-builder/src/Model/Security/Gate/ before you
write your own. They are short, and the CAPTCHA gate is the closest starting point for a
provider swap.
Step 2: Return the Right Verdict
A gate never writes an HTTP response itself. It returns a Verdict value object and the
controller maps that verdict to a status code and JSON shape. Honor the contract and the
controller does the rest. The verdicts are:
Verdict::pass()- the gate is satisfied; continue to the next gate.Verdict::silentSuccess()- the honeypot's quiet bot-trap. Returns a fake success to the caller so a bot thinks it won, while no email is sent. Use this when you want to reject without telling the client it was rejected.Verdict::rejectFormKey()- the CSRF form key was missing or wrong.Verdict::rejectCaptcha()- the CAPTCHA challenge failed.rejectCaptchaMissing(),rejectCaptchaInvalid()andrejectCaptchaError()narrow the reason, which is what lets the controller say "please complete the CAPTCHA" instead of the generic security copy.
Pick the verdict that matches the failure. For a CAPTCHA provider, rejectCaptcha() is the
natural rejection; for a silent anti-bot check, silentSuccess() mirrors the honeypot's
behavior. Returning pass() when your gate does not apply (CAPTCHA disabled, for instance)
lets the chain continue cleanly.
Match the shipped verdicts, not your own status codes
The controller only knows the four verdicts above. If your gate needs a rejection shape that none of them cover, model it on the closest existing verdict rather than trying to return a bespoke response. The mapping to HTTP lives in the controller, not the gate, and that is deliberate so every gate stays a pure decision.
Step 3: Register the Gate
How you register depends on what you built in Step 1.
To swap the CAPTCHA provider, bind your class as a preference for CaptchaGateInterface.
The shipped reCAPTCHA gate is the default binding, and this one line replaces it. The gate
keeps its position in the chain; you just change the implementation behind the CAPTCHA slot.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Hyva\FormBuilder\Model\Security\CaptchaGateInterface"
type="Acme\HCaptchaGate\Model\Security\Gate\HCaptchaGate"/>
</config>
To add an extra gate rather than replace the CAPTCHA provider, append it to
SecurityGuard's gates array. The array is ordered, so sortOrder controls when your
check runs relative to the shipped gates: an allowlist that should run early gets a low
sort order, a final signed-token check gets a high one.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Hyva\FormBuilder\Model\Security\SecurityGuard">
<arguments>
<argument name="gates" xsi:type="array">
<item name="acme_allowlist" xsi:type="object" sortOrder="40">
Acme\AllowlistGate\Model\Security\Gate\AllowlistGate
</item>
</argument>
</arguments>
</type>
</config>
Magento merges array arguments by item key, so your gate slots in alongside the shipped three without colliding.
Reference Implementation
The three shipped gates and the verdict contract are the working reference. In the
module-form-builder package:
src/Model/Security/Gate/- the form-key, honeypot and CAPTCHA gates. The CAPTCHA gate is the closest starting point for a provider swap; the honeypot gate is the one that returnssilentSuccess().src/Model/Security/Verdict.php- the value object and its named constructors (pass,silentSuccess,rejectFormKey,rejectCaptcha).src/Model/Security/SecurityGuard.php- how the orderedgatesarray is walked and where the first rejection stops the chain.src/etc/di.xml- the shippedgateswiring and theCaptchaGateInterfacepreference you override.
These show every pattern on this page in production code.
Related Topics
- Architecture - where the guard sits in the request flow, how the verdict becomes an HTTP response, and the entity-level settings seam the CAPTCHA toggle is read through.
- Create a Custom Form Root - the root contract, and why the CAPTCHA toggle is not part of it.
- Add Custom Validation - the other server-side check a submission passes, after the gates.
- Spam Protection - the CAPTCHA toggle in Form Settings, and the store-level reCAPTCHA setup.