How to Block AI Bots on Slim 4 (PHP): Complete 2026 Guide
Slim 4 is a PHP micro-framework that adopts PSR-15 for middleware and PSR-7 for HTTP messages. Bot blocking uses a MiddlewareInterface class with a single process() method — return a new 403 response to block (Layer 4), or call $handler->handle($request) and add the X-Robots-Tag header to the result (Layer 3). PSR-7 responses are immutable — always capture the return value of withHeader().
PSR-7 immutability — the #1 gotcha
PSR-7 HTTP message objects are immutable. withHeader(), withStatus(), and withAddedHeader() return a new instance — they do not modify the existing object. If you write $response->withHeader(...) without assigning the result, the header is silently discarded. Always write: $response = $response->withHeader(...); return $response;
Protection layers
Layer 1: robots.txt
Place robots.txt in public/ — Slim 4's document root (where index.php lives). Your web server serves files from this directory directly, before PHP runs.
# public/robots.txt User-agent: * Allow: / User-agent: GPTBot User-agent: ClaudeBot User-agent: anthropic-ai User-agent: Google-Extended User-agent: CCBot User-agent: Bytespider User-agent: Applebot-Extended User-agent: PerplexityBot User-agent: Diffbot User-agent: cohere-ai User-agent: FacebookBot User-agent: omgili User-agent: omgilibot User-agent: Amazonbot User-agent: DeepSeekBot User-agent: MistralBot User-agent: xAI-Bot User-agent: AI2Bot Disallow: /
Alternatively, define a Slim route for dynamic generation:
$app->get('/robots.txt', function (Request $request, Response $response) {
$body = <<<ROBOTS
User-agent: *
Allow: /
User-agent: GPTBot
Disallow: /
ROBOTS;
$response->getBody()->write($body);
return $response->withHeader('Content-Type', 'text/plain');
});Layer 2: noai meta tag
Add the meta tag to your PHP or Twig base template. Pass an override variable from controllers that need different behaviour:
Plain PHP layout (app/Views/layout.php)
<!-- app/Views/layout.php --> <meta name="robots" content="<?= htmlspecialchars($robots ?? 'noai, noimageai') ?>">
Twig template (templates/base.html.twig)
{# templates/base.html.twig #}
<meta name="robots" content="{{ robots|default('noai, noimageai') }}">Override in route handler
// Allow indexing on a specific public page
return $this->view->render($response, 'page.html.twig', [
'robots' => 'index, follow',
]);Layers 3 & 4: PSR-15 middleware
Implement Psr\Http\Server\MiddlewareInterface with a single process() method. Return a new response to block. Call $handler->handle($request) to pass through.
src/Middleware/AiBotBlocker.php
<?php
declare(strict_types=1);
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class AiBotBlocker implements MiddlewareInterface
{
private const AI_BOT_PATTERNS = [
'gptbot', 'chatgpt-user', 'oai-searchbot',
'claudebot', 'anthropic-ai', 'claude-web',
'google-extended', 'ccbot', 'bytespider',
'applebot-extended', 'perplexitybot', 'diffbot',
'cohere-ai', 'facebookbot', 'meta-externalagent',
'omgili', 'omgilibot', 'amazonbot',
'deepseekbot', 'mistralbot', 'xai-bot', 'ai2-bot',
];
private const EXEMPT_PATHS = [
'/robots.txt',
'/sitemap.xml',
'/favicon.ico',
];
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$path = $request->getUri()->getPath();
// Always pass through exempt paths
if (in_array($path, self::EXEMPT_PATHS, true)) {
return $handler->handle($request);
}
$ua = strtolower($request->getHeaderLine('User-Agent'));
foreach (self::AI_BOT_PATTERNS as $pattern) {
if (str_contains($ua, $pattern)) {
// Layer 4: hard 403 — do NOT call $handler->handle()
$response = new Response(403);
$response->getBody()->write('Forbidden');
return $response->withHeader('Content-Type', 'text/plain');
}
}
// Layer 3: X-Robots-Tag on legitimate responses
// PSR-7 is immutable — withAddedHeader() returns a NEW instance
$response = $handler->handle($request);
return $response->withAddedHeader('X-Robots-Tag', 'noai, noimageai');
}
}Key points
- Blocking: return a new
Responsedirectly — do not call$handler->handle(). Calling the handler would execute the route and any remaining middleware. - PSR-7 immutability:
$response->withAddedHeader()returns a new object. The original is unchanged. Always assign:$response = $response->withAddedHeader(...) withAddedHeader()vswithHeader()— usewithAddedHeader()when a header might already be set (appends); usewithHeader()to replace. ForX-Robots-Tag, either works since it is not typically set by route handlers.str_contains()requires PHP 8.0+. Slim 4 supports PHP 7.4+, so usestrpos($ua, $pattern) !== falseif you target PHP 7.4.$response->getBody()->write()modifies the stream in place (streams are mutable, unlike response objects). No reassignment needed for the body.
Registering the middleware
<?php // public/index.php (or app bootstrap) use App\Middleware\AiBotBlocker; use Slim\Factory\AppFactory; $app = AppFactory::create(); // Global — runs on every request // Add last (outermost) so it intercepts before other middleware $app->addMiddleware(new AiBotBlocker()); // Slim runs middleware in LIFO order for the response phase. // Add bot blocker after (above) other middleware like auth and body parsing. $app->addBodyParsingMiddleware(); $app->run();
Route-scoped blocking
To protect only API routes and leave frontend routes unaffected:
use Slim\Routing\RouteCollectorProxy;
// Single route
$app->get('/api/products', ProductHandler::class)->add(new AiBotBlocker());
// Route group — all /api/* routes protected
$app->group('/api', function (RouteCollectorProxy $group) {
$group->get('/products', ProductHandler::class);
$group->get('/orders', OrderHandler::class);
$group->post('/cart', CartHandler::class);
})->add(new AiBotBlocker());Route-level middleware runs in LIFO order — middleware added closest to the route runs first. Add the bot blocker outermost (last) to intercept before auth or body parsing.
Verification
# Layer 1 — robots.txt curl https://your-slim-app.com/robots.txt # Layer 3 — X-Robots-Tag on legitimate request curl -I https://your-slim-app.com/api/products # Expected: X-Robots-Tag: noai, noimageai # Layer 4 — hard block on bot user-agent curl -A "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)" \ -I https://your-slim-app.com/api/products # Expected: HTTP/1.1 403 Forbidden # robots.txt must pass through even for bot UAs curl -A "GPTBot" -I https://your-slim-app.com/robots.txt # Expected: HTTP/1.1 200 OK
Slim 3 vs Slim 4
If you are upgrading from Slim 3, the middleware signature is different:
Slim 3 (callable, NOT PSR-15)
function ($request, $response, $next) {
// ... check UA ...
if ($isBot) {
return $response->withStatus(403);
}
$response = $next($request, $response);
return $response->withHeader('X-Robots-Tag', 'noai, noimageai');
}Slim 4 (PSR-15 MiddlewareInterface)
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// ... check UA ...
if ($isBot) {
return new Response(403); // No $handler->handle()
}
$response = $handler->handle($request);
return $response->withAddedHeader('X-Robots-Tag', 'noai, noimageai');
}FAQ
How does PSR-15 middleware work in Slim 4?
PSR-15 defines a MiddlewareInterface with process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface. To block: return a new ResponseInterface directly without calling $handler->handle($request). To pass through: call $handler->handle($request) to get the response, optionally modify it, then return it. This is different from Slim 3 where you called $next($request, $response) with three parameters.
Where does robots.txt go in a Slim 4 app?
In public/ — the document root your web server points to (same directory as index.php). Apache and nginx serve files from here directly, before PHP processes the request. No Slim route is needed. If you need to generate robots.txt dynamically (e.g. different rules per environment), define a Slim route instead: $app->get('/robots.txt', ...).
Why must I capture the return value of withHeader()?
PSR-7 defines HTTP message objects as immutable. withHeader(), withStatus(), withAddedHeader(), and withBody() all return a new instance with the change — they do not modify the original. If you write $response->withHeader('X-Robots-Tag', ...) without assigning the result, the return value is discarded and the header is never set. Always write: $response = $response->withHeader('X-Robots-Tag', 'noai, noimageai'); return $response;
Should I use addMiddleware() (global) or ->add() (route-scoped)?
Global ($app->addMiddleware(new AiBotBlocker())) if you want all routes protected — simpler, no per-route config. Route-scoped (->add()) if your app serves both a public frontend (which should be indexed) and an API (which should be protected). If you use a static public/ robots.txt, the file is served before Slim and never touches route middleware — exempt paths in the middleware only protect against routes that define /robots.txt dynamically.
What is the difference between Slim 3 and Slim 4 middleware?
Slim 3 used a three-parameter callable (Request $request, Response $response, callable $next): Response. Slim 4 adopts PSR-15 MiddlewareInterface with process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface — two parameters (no $response), and $next is replaced by $handler->handle(). PSR-15 middleware classes from other frameworks (Symfony, Mezzio) can be reused in Slim 4 without modification.
Is your site protected from AI bots?
Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.