cleantalk/php-antispam
CleanTalk PHP Anti-Spam is a lightweight PHP library that blocks spam form and comment submissions without CAPTCHA. It connects to the CleanTalk cloud service and works with Laravel and other PHP projects.
Install via Composer (the package uses PSR-4 autoloading, requires ext-curl and ext-json):
composer require cleantalk/php-antispam
Get an access key — register the site at cleantalk.org and copy the key from the dashboard. The key is bound to a domain, so use one key per site/environment (you can register a separate key for staging).
Construct the client once per request lifecycle and configure it before calling any check:
use Cleantalk\Antispam\Cleantalk;
use Cleantalk\Antispam\CleantalkRequest;
$ct = new Cleantalk();
$ct->authKey = getenv('CLEANTALK_KEY');
$ct->serverUrl = 'https://moderate.cleantalk.org';
$ct->serverPort = 443;
$ct->serverTimeout = 5;
$ct->workUrl = 'https://your-site.test';
$ct->agent .= ' my-app/1.0';
First use case — protect a comment/contact form with isSpam():
$req = new CleantalkRequest();
$req->message = $comment;
$req->senderIp = $_SERVER['REMOTE_ADDR'];
$req->senderEmail = $email;
$req->senderNickname = $name;
$req->jsOn = 1; // set by JS only if JS actually ran
$req->submitTime = time() - $formRenderedAt; // seconds the user spent on the form
$res = $ct->isSpam($req);
if ((int) $res->allow === 0) {
// treat as spam; $res->comment holds a human-readable reason
}
Where to look first:
src/CleantalkRequest.php — the full list of fields you can supply (this file is the real documentation; the README only shows a subset).src/Cleantalk.php — available check methods (isSpam, checkNewuser, checkCaptcha, getApiKey) and transport config (serverUrl, serverPort, serverTimeout, debug).src/CleantalkResponse.php — response contract (allow, comment, errno, errstr, id, stop_queue, inactive).src/CleantalkHelper.php — helpers such as IP extraction you can reuse.Wrap it in a small service/repository. Don't scatter new Cleantalk() across controllers. Create one service that reads key/URL from config and exposes isSpam(array $payload): bool plus checkRegistration(array $payload): bool. In Laravel, bind it as a singleton in a service provider and inject it.
final class AntiSpam
{
public function __construct(private Cleantalk $ct) {}
public function check(array $data): CleantalkResponse
{
$req = new CleantalkRequest();
$req->message = $data['message'] ?? '';
$req->senderIp = $data['ip'];
$req->senderEmail = $data['email'] ?? '';
$req->senderNickname = $data['name'] ?? '';
$req->jsOn = $data['js_on'] ?? 0;
$req->submitTime = $data['submit_time'] ?? 0;
$req->agent = 'php app/1.0';
return $this->ct->isSpam($req);
}
}
Middleware pattern (Laravel / any framework with middleware). Apply to POST routes that create user content — comments, contact forms, reviews, ticket creation. Check the response, and on allow === 0 abort with a user-facing message; don't dump $res->comment verbatim (it's a debug hint, not UX copy).
// app/Http/Middleware/CleanTalkSpam.php
public function handle($request, Closure $next)
{
$res = $this->antispam->check([
'message' => (string) $request->input('message'),
'email' => (string) $request->input('email'),
'name' => (string) $request->input('name'),
'ip' => $request->ip(),
'js_on' => (int) $request->boolean('js_on'),
'submit_time' => time() - (int) $request->input('form_ts', time()),
]);
if ($res->errno === 0 && (int) $res->allow === 0) {
return response()->json(['error' => 'Your message was flagged as spam.'], 422);
}
return $next($request);
}
Two-phase form integration. The invisible scoring is far better when you feed it real signals:
form_ts (server time() at render) and a hidden js_on that gets flipped to 1 by a tiny inline script on submit.submit_time = time() - form_ts. A value under ~2–3 seconds is a strong bot signal (and you'll want a minimum on the server side too, otherwise humans with autofill trip it).Use the right method per context.
isSpam() for messages, comments, contact forms, reviews.checkNewuser() for registration forms (different heuristics, plus email/username signals).getApiKey() if you want to drop the CleanTalk JS snippet in and let the browser pre-score — useful for high-traffic forms where you want to avoid a server round trip on every submission.Fail policy: choose explicitly. Wrap the call in try/catch and branch on $res->errno:
errno === 0 → trust allow.errno !== 0 (timeout, DNS, blocked egress) → either allow (fail-open, better for conversions) or reject (fail-closed, only if you also have a honeypot/rate limit). Never silently swallow an error without logging errno/errstr, or you'll be blind to a dead integration.Skip checks for trusted traffic. Don't call the API for authenticated members with a good history, admin-preview requests, or non-mutating requests. It costs latency and quota.
Log the returned id. It's the CleanTalk-side request id, and it's what support asks for when you need to dispute a false positive.
$request->auth_key, $request->sender_ip, $request->js_on; current versions use camelCase (authKey, senderIp, jsOn). Always verify against the installed src/CleantalkRequest.php rather than copy-pasting a blog snippet.allow is inverted from what you expect. 1 = allowed/not spam, 0 = spam/blocked. Read the value as an int; some code paths return strings.moderate.cleantalk.org:443. Test with curl -v https://moderate.cleantalk.org from the same PHP user; a blocked firewall makes every submission pass or fail uniformly, which looks like "the plugin stopped working".jsOn must reflect reality. Hardcoding 1 fakes the signal and degrades scoring; hardcoding 0 for everyone throws away free accuracy. Set it from a JS-driven hidden field.submitTime is a duration, not a timestamp. Pass seconds-since-form-render. Passing time() directly makes every user look like they submitted instantly or spent decades on the page. If you truly don't know, pass 0 rather than a wrong value.REMOTE_ADDR will be your load balancer/Cloudflare edge. Don't trust X-Forwarded-For blindly (it's user-supplied); use your framework's trusted-proxy config or CleantalkHelper::ipGet(), and ensure the IP is IPv4/IPv6 valid — malformed IPs are silently dropped and degrade scoring.errno set, allow often still 1. A mismatched domain/key combo won't hard-fail the request, so the integration looks "fine" while every check passes. Assert errno === 0 in your tests.comment is not user-facing copy. It's an English debug string (or localized via responseLang). Show your own message and keep comment for logs.senderInfo must be valid JSON if you pass it. Malformed JSON is dropped without an error.serverTimeout explicitly. Defaults are not tuned for web request paths; 3–5s is a sane ceiling. A slow CleanTalk response will otherwise stall your form submission.debug = 1 dumps the raw request/response — invaluable when debugging scoring, but never leave it on in production (it can print the auth key and user data).serverUrl at a local stub server (or subclass Cleantalk and override the HTTP send method) and assert on errno/allow combinations — success, spam, timeout, and invalid key. Don't hit the live API from CI.id so you can ask CleanTalk to whitelist.Cleantalk instance per check in a long-running worker (Octane/queue daemon) unless you rebuild config each time; it's cheap, but the config assignment is the part that matters.inactive, stop_queue) and request fields have been added/renamed across releases, and silent renames are the usual cause of "spam protection stopped working after composer update".How can I help you explore Laravel packages today?