To block bad bots in PHP without blocking legitimate search crawlers, perform the bot check on the server, send a trustworthy visitor IP and the current request context to the detection service, and enforce the returned blockAccess decision. Do not block every request with isBot: 1: a verified crawler can be automated and still be allowed by the active policy.
The safe PHP request flow
A reliable integration separates identity, policy, and enforcement. PHP collects the server-observed request details and calls Blocker V2. Stopbot evaluates the named configuration and returns classification, policy, and page-response fields. PHP then allows the request or applies the configured response. Search-crawler verification remains part of policy instead of becoming a hard-coded user-agent exception.
- Run the check only from a server-side PHP entry point.
- Read the visitor address from
REMOTE_ADDRunless a trusted proxy is guaranteed to overwrite a specific client-IP header. - Send the visitor user agent and current URL as context.
- Require a successful API response before interpreting decision fields.
- Use
blockAccessas the final allow or deny decision. - Apply only the configured
pageResponseTypeand prevent a redirect back to the same page.
Create a production-oriented Blocker V2 helper
The example below protects public HTML page requests and skips common static assets. It uses STOPBOT_API_KEY and STOPBOT_BLOCKERV2_CONFNAME from the environment. Set STOPBOT_TRUST_PROXY_HEADERS=1 only when the origin cannot be reached around the trusted proxy and that proxy overwrites the client-IP header.
<?php
declare(strict_types=1);
const STOPBOT_BLOCKER_V2_ENDPOINT =
'https://api.stopbot.net/services/blockerv2';
function stopbot_is_page_request(): bool
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'HEAD'], true)) {
return false;
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
return preg_match(
'/\.(?:css|js|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|pdf|zip)$/i',
$path
) !== 1;
}
function stopbot_client_ip(bool $trustProxyHeaders): ?string
{
$candidates = [];
if ($trustProxyHeaders) {
$candidates[] = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
$candidates[] = $_SERVER['HTTP_X_REAL_IP'] ?? '';
}
$candidates[] = $_SERVER['REMOTE_ADDR'] ?? '';
foreach ($candidates as $candidate) {
$candidate = trim((string) $candidate);
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
return $candidate;
}
}
return null;
}
function stopbot_current_url(bool $trustProxyHeaders): string
{
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
if ($trustProxyHeaders) {
$forwarded = strtolower(trim(explode(
',',
(string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')
)[0]));
$https = $forwarded === 'https';
}
$host = (string) ($_SERVER['SERVER_NAME'] ?? $_SERVER['HTTP_HOST'] ?? '');
$host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', $host) ?: 'localhost';
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
return substr(($https ? 'https' : 'http') . '://' . $host . $uri, 0, 2048);
}
function stopbot_request(
string $apiKey,
string $confName,
string $ip,
string $currentUrl
): ?array {
$query = http_build_query([
'apikey' => $apiKey,
'confname' => $confName,
'ip' => $ip,
'ua' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512),
'url' => $currentUrl,
], '', '&', PHP_QUERY_RFC3986);
$curl = curl_init(STOPBOT_BLOCKER_V2_ENDPOINT . '?' . $query);
if ($curl === false) {
return null;
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 1500,
CURLOPT_TIMEOUT_MS => 3000,
CURLOPT_NOSIGNAL => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'Stopbot-PHP-Integration/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = curl_errno($curl);
curl_close($curl);
if (!is_string($body) || $httpCode < 200 || $httpCode >= 300) {
error_log(sprintf(
'[Stopbot] API request failed (HTTP %d, cURL %d).',
$httpCode,
$curlError
));
return null;
}
try {
$decision = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $error) {
error_log('[Stopbot] API returned invalid JSON.');
return null;
}
if (!is_array($decision) || ($decision['status'] ?? '') !== 'success') {
error_log('[Stopbot] API did not return a successful decision.');
return null;
}
return $decision;
}
function stopbot_url_identity(string $url): ?string
{
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$host = strtolower((string) $parts['host']);
$port = (int) ($parts['port'] ?? 0);
if ($port !== 0 && $port !== 80 && $port !== 443) {
$host .= ':' . $port;
}
$path = rawurldecode((string) ($parts['path'] ?? '/'));
$path = preg_replace('#/+#', '/', $path) ?: '/';
$path = rtrim($path, '/') ?: '/';
return $host . $path;
}
function stopbot_apply_decision(array $decision, string $currentUrl): void
{
if ((int) ($decision['blockAccess'] ?? 0) !== 1) {
return;
}
$type = (string) ($decision['pageResponseType'] ?? 'None');
$contents = trim((string) ($decision['pageResponseContents'] ?? ''));
if ($type === 'RedirectURL') {
$scheme = strtolower((string) parse_url($contents, PHP_URL_SCHEME));
$validTarget = filter_var($contents, FILTER_VALIDATE_URL) !== false
&& in_array($scheme, ['http', 'https'], true);
$current = stopbot_url_identity($currentUrl);
$target = stopbot_url_identity($contents);
$samePage = $current !== null && $target !== null
&& hash_equals($current, $target);
if ($validTarget && !$samePage) {
header('Location: ' . $contents, true, 302);
exit;
}
error_log('[Stopbot] Unsafe or same-page redirect was skipped.');
return;
}
if ($type === 'HTTPStatusCode') {
$status = filter_var($contents, FILTER_VALIDATE_INT);
if (is_int($status) && $status >= 400 && $status <= 599) {
header('Cache-Control: no-store');
http_response_code($status);
exit;
}
error_log('[Stopbot] Invalid HTTP status response was skipped.');
}
}
function stopbot_protect_page(): void
{
if (!stopbot_is_page_request()) {
return;
}
$apiKey = (string) (getenv('STOPBOT_API_KEY') ?: '');
$confName = trim((string) (getenv('STOPBOT_BLOCKERV2_CONFNAME') ?: ''));
$trustProxyHeaders = getenv('STOPBOT_TRUST_PROXY_HEADERS') === '1';
if (preg_match('/^[A-Za-z0-9]{32}$/', $apiKey) !== 1 || $confName === '') {
error_log('[Stopbot] API key or configuration name is missing.');
return;
}
$ip = stopbot_client_ip($trustProxyHeaders);
if ($ip === null) {
error_log('[Stopbot] A valid visitor IP was not available.');
return;
}
$currentUrl = stopbot_current_url($trustProxyHeaders);
$decision = stopbot_request($apiKey, $confName, $ip, $currentUrl);
if ($decision !== null) {
stopbot_apply_decision($decision, $currentUrl);
}
}
stopbot_protect_page();Why blockAccess controls enforcement
isBot describes automation classification. blockAccess is the final policy outcome after the active Blocker V2 configuration evaluates the request. This distinction preserves legitimate automation: a verified crawler may return isBot: 1 and blockAccess: 0, so blocking only on isBot would create an avoidable crawling and indexing problem.
{
"isBot": 1,
"blockAccess": 0,
"threatURL": 0,
"detectActivity": "[Allow] - SearchEngine (Google)",
"pageResponseType": "None",
"pageResponseContents": "Stay On Page",
"status": "success"
}The example PHP helper therefore checks status first and enforces only blockAccess: 1. detectActivity is useful for investigation and visitor logs, but it should not replace the final decision field.
Trust proxy headers only behind a trusted proxy
CF-Connecting-IP, X-Real-IP, and X-Forwarded-For are ordinary request headers unless the network path makes them trustworthy. Leave STOPBOT_TRUST_PROXY_HEADERS=0 on normal hosting. Enable it only when every public request passes through a trusted proxy or load balancer, the origin rejects direct public traffic, and the proxy overwrites rather than appends the chosen client-IP header.
- Use
REMOTE_ADDRwhen the PHP server receives visitors directly. - For Cloudflare, restrict origin access to Cloudflare or a private proxy path before trusting
CF-Connecting-IP. - For Nginx, Apache, Caddy, or a load balancer, document which header is overwritten and which proxy addresses are trusted.
- Do not accept the first
X-Forwarded-Forvalue from an unrestricted origin.
Prevent same-page redirect loops
A deny policy may use RedirectURL, but the destination must not resolve to the page currently being checked. The helper compares host and normalized path, ignores the query string, treats HTTP and HTTPS as the same identity, and collapses repeated or trailing slashes. If the target is the same page, it skips that redirect instead of producing ERR_TOO_MANY_REDIRECTS.
Keep static assets and unrelated requests out of the check
Call this helper from the public PHP front controller or the entry files that render HTML. The extension guard prevents common images, fonts, stylesheets, scripts, and downloads from consuming API requests when they pass through the same router. Protect form submissions and APIs with their own route-aware policy, rate limits, authentication, and business checks instead of assuming one page-level bot decision covers every abuse case.
Test the integration before enabling enforcement
- Create a Blocker V2 configuration in the Stopbot panel and copy its exact configuration name.
- Set the API key and configuration name as server environment variables, then confirm they are absent from page source and client-side requests.
- Keep proxy-header trust disabled until the origin and proxy trust boundary has been verified.
- Test a normal browser request and confirm it returns the intended page.
- Test the configured deny response and confirm the HTTP status or redirect matches the Blocker V2 policy.
- Test the redirect destination directly and confirm it cannot redirect to itself.
- Inspect a verified crawler decision and confirm
isBot: 1withblockAccess: 0remains allowed. - Use Google Search Console URL Inspection on important public URLs after deployment and monitor origin logs for unexpected 403 responses or redirect loops.
Sources
Continue building
Put the decision fields into practice.
Review the current API documentation before changing production traffic handling.



