In the previous post we compared reCAPTCHA with privacy-first alternatives, and the recommendation for PrestaShop 8 stores with European traffic was Cloudflare Turnstile. This is the technical tutorial: how to add it to the contact form, validate the token on the server and log every attempt. The code below is the same code that ships in the Zeyvro Turnstile 1.1.10 module, copied as is, not a simplified version. The code comments are in Spanish because that is how they appear in the module source.
Before you start
- Free Cloudflare account. Your domain does not need to use Cloudflare nameservers to use Turnstile.
- A widget. dash.cloudflare.com → Turnstile → Add widget: your domain, managed mode and a name. Cloudflare gives you a Site Key (public, goes in the HTML) and a Secret Key (private, server only).
- PrestaShop 8.0.0 to 9.1.3, the range the module declares. PrestaShop 1.7 is not covered.
- PHP with the cURL extension, to validate the token against Cloudflare.
Step 1: module structure
zeyvro_turnstile/
├── zeyvro_turnstile.php ← main class (hooks, validation, log)
├── config.xml
├── classes/ZeyvroModuleTrait.php ← shared base of Zeyvro modules (BO tab, caches)
├── controllers/admin/AdminZeyvroTurnstileController.php ← settings and log
├── views/templates/
│ ├── admin/settings.tpl
│ └── front/turnstile_widget.tpl ← widget injection
├── sql/install.sql
├── sql/uninstall.sql ← manual, documented table removal
└── upgrade/ ← per-version upgrade scripts
Step 2: the three hooks
The module registers three hooks and does not use actionContactFormSubmitCaptcha: its behaviour changes between PrestaShop 8 versions and it is not available in every store with a custom contact module.
public function install(): bool
{
return parent::install()
&& $this->installBase() // trait: hook auto-reparación + ensureTabs + clearAllCaches
&& $this->installSql()
&& $this->installConfig()
&& $this->registerHook('displayHeader')
&& $this->registerHook('displayBeforeBodyClosingTag')
&& $this->registerHook('actionFrontControllerSetMedia');
}
displayHeader: loads the Turnstile script, on the contact page only.displayBeforeBodyClosingTag: injects the widget into the form.actionFrontControllerSetMedia: validates the token on the server before PrestaShop processes the message.
Step 3: load the script on the contact page only
/**
* Inyecta el script de la API de Turnstile solo en la página de contacto.
*/
public function hookDisplayHeader($params): string
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
$phpSelf = isset($this->context->controller->php_self)
? $this->context->controller->php_self
: null;
if ($phpSelf !== 'contact') {
return '';
}
if (empty(Configuration::get('ZEYVRO_TURNSTILE_SITE_KEY'))) {
return '';
}
$this->context->controller->registerJavascript(
'turnstile-api',
'https://challenges.cloudflare.com/turnstile/v0/api.js',
['server' => 'remote', 'position' => 'head', 'priority' => 150, 'attributes' => 'async defer']
);
return '';
}
The check is php_self === 'contact': no other page of the store loads anything from Cloudflare.
Step 4: inject the widget without touching the theme
/**
* Inyecta el widget Turnstile en el formulario de contacto vía JS.
*/
public function hookDisplayBeforeBodyClosingTag($params): string
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return '';
}
$phpSelf = isset($this->context->controller->php_self)
? $this->context->controller->php_self
: null;
if ($phpSelf !== 'contact') {
return '';
}
$siteKey = Configuration::get('ZEYVRO_TURNSTILE_SITE_KEY');
if (empty($siteKey)) {
return '';
}
$mode = Configuration::get('ZEYVRO_TURNSTILE_MODE') ?: 'managed';
$this->context->smarty->assign([
'sbt_site_key' => $siteKey,
'sbt_mode' => $mode,
]);
return $this->display(__FILE__, 'views/templates/front/turnstile_widget.tpl');
}
The turnstile_widget.tpl template finds the contact form by its fields (message, from, id_contact and the submitMessage button) and places the widget right before the button. It works with any theme, with no overrides:
<div id="sbt-placeholder"
data-sitekey="{$sbt_site_key|escape:'htmlall':'UTF-8'}"
data-mode="{$sbt_mode|escape:'html':'UTF-8'}"
style="display:none;"
aria-hidden="true"></div>
{literal}
<script>
(function () {
function sbtInjectWidget() {
var placeholder = document.getElementById('sbt-placeholder');
if (!placeholder) { return; }
var siteKey = placeholder.getAttribute('data-sitekey');
var mode = placeholder.getAttribute('data-mode') || 'managed';
// Detectar forms de contacto por inputs característicos del módulo contactform PS8
var contactForms = [];
document.querySelectorAll('form').forEach(function (f) {
if (f.querySelector('textarea[name="message"]')
&& f.querySelector('input[name="from"]')
&& f.querySelector('select[name="id_contact"]')
&& f.querySelector('button[name="submitMessage"], input[name="submitMessage"]')) {
contactForms.push(f);
}
});
if (contactForms.length === 0) { return; }
contactForms.forEach(function (form) {
// No duplicar si ya hay un widget en ESTE form
if (form.querySelector('.cf-turnstile')) { return; }
var widget = document.createElement('div');
widget.className = 'cf-turnstile sbt-turnstile-widget';
widget.style.marginBottom = '12px';
widget.setAttribute('data-sitekey', siteKey);
widget.setAttribute('data-theme', 'auto');
if (mode === 'non-interactive') {
widget.setAttribute('data-appearance', 'always');
widget.setAttribute('data-execution', 'render');
} else if (mode === 'invisible') {
widget.setAttribute('data-appearance', 'interaction-only');
widget.setAttribute('data-execution', 'render');
widget.setAttribute('data-size', 'invisible');
}
var btn = form.querySelector('button[name="submitMessage"]')
|| form.querySelector('button[type="submit"]')
|| form.querySelector('input[type="submit"]');
if (btn) {
btn.parentNode.insertBefore(widget, btn);
} else {
form.appendChild(widget);
}
});
if (placeholder.parentNode) { placeholder.parentNode.removeChild(placeholder); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sbtInjectWidget);
} else {
sbtInjectWidget();
}
})();
</script>
{/literal}
Step 5: validate the token on the server
This is the part that actually stops bots. Without it, a bot that sends the POST directly, without running JavaScript, gets through.
/**
* Validación server-side del token Turnstile en el formulario de contacto.
*/
public function hookActionFrontControllerSetMedia($params): void
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_ENABLED')) {
return;
}
if (!isset($this->context->controller->php_self)
|| $this->context->controller->php_self !== 'contact') {
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !Tools::isSubmit('submitMessage')) {
return;
}
$secretKey = Configuration::get('ZEYVRO_TURNSTILE_SECRET_KEY');
if (empty($secretKey)) {
return;
}
$token = (string) Tools::getValue('cf-turnstile-response', '');
$remoteIp = Tools::getRemoteAddr();
$result = $this->verifyCloudflareTurnstile($secretKey, $token, $remoteIp);
$this->logVerification($remoteIp, $result);
if (!$result['success']
&& Configuration::get('ZEYVRO_TURNSTILE_ACTION_ON_FAIL') !== 'log_only') {
$this->context->controller->errors[] = $this->l(
'Security verification failed. Please try again.'
);
// v1.1.8 — contactform < 4.4.3 (PS 8.0 / 8.1) guarda el mensaje en Servicio al cliente sin mirar
// controller->errors. Sin submitMessage no entra en sendMessage(); el email y el mensaje se conservan.
foreach (['submitMessage', 'submitMessage_x', 'submitMessage_y'] as $key) {
unset($_POST[$key], $_GET[$key]);
}
}
}
If verification fails and the configured action is block, the module adds the error to the form and removes submitMessage from the request. That second step exists because the contact module before 4.4.3 (PrestaShop 8.0 and 8.1) saved the message even when there were errors.
private function verifyCloudflareTurnstile(string $secret, string $token, string $remoteIp): array
{
$timeout = max(1, (int) Configuration::get('ZEYVRO_TURNSTILE_API_TIMEOUT'));
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$postData = http_build_query([
'secret' => $secret,
'response' => $token,
'remoteip' => $remoteIp,
]);
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || !empty($curlError)) {
PrestaShopLogger::addLog('[zeyvro_turnstile] cURL error: ' . $curlError, 3);
return ['success' => false, 'error_codes' => ['network-error'], 'score' => null];
}
$data = json_decode($response, true);
if (!is_array($data)) {
return ['success' => false, 'error_codes' => ['invalid-response'], 'score' => null];
}
return [
'success' => !empty($data['success']),
'error_codes' => $data['error-codes'] ?? [],
'score' => isset($data['score']) ? (float) $data['score'] : null,
];
}
If Cloudflare does not answer within the timeout (5 s by default, configurable), verification counts as failed with the code network-error. What happens next is your choice in the settings: block (default) or log only.
Step 6: log every attempt
Each verification is stored in the zeyvro_turnstile_log table: IP, user agent, date, result and error codes.
private function installSql(): bool
{
return Db::getInstance()->execute('
CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . "zeyvro_turnstile_log` (
`id_log` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(255) NOT NULL DEFAULT '',
`date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`success` TINYINT(1) NOT NULL DEFAULT 0,
`score` DECIMAL(4,2) NULL,
`error_codes` TEXT NULL,
PRIMARY KEY (`id_log`),
KEY `idx_date` (`date_add`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
private function logVerification(string $ip, array $result): void
{
if (!(int) Configuration::get('ZEYVRO_TURNSTILE_LOG_ENABLED')) {
return;
}
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
$errorCodes = !empty($result['error_codes']) ? implode(',', $result['error_codes']) : null;
Db::getInstance()->insert('zeyvro_turnstile_log', [
'ip' => pSQL($ip),
'user_agent' => pSQL(Tools::substr($ua, 0, 255)),
'date_add' => date('Y-m-d H:i:s'),
'success' => (int) $result['success'],
'score' => $result['score'] !== null ? (float) $result['score'] : null,
'error_codes' => $errorCodes !== null ? pSQL($errorCodes) : null,
]);
}
From the Back Office you can view the log and delete entries older than 30 days. On uninstall, the table and the settings are kept; sql/uninstall.sql removes them for good.
Cloudflare errors you will see
missing-input-response: no token arrived; the widget JavaScript did not run.invalid-input-response: malformed or expired token.timeout-or-duplicate: token already used or older than 5 minutes; normal on long forms.invalid-input-secret: the Secret Key is wrong.network-error: set by the module when Cloudflare does not answer in time.
What it does not cover
- Customer login and registration: it only protects the native contact form.
- Hand-made forms inside CMS pages.
- Forms the theme loads via AJAX after the page renders (for example, in a popup).
Shortcut: the ready-made module
Everything above is the Zeyvro Turnstile module: free, MIT licence, with settings and the log in the Back Office. Paste the Site Key and the Secret Key, enable it, done.
The series continues with how to measure how much spam Turnstile blocks with your own data. If anything in this tutorial does not match your store, write to hola@zeyvro.com.