How to Block AI Bots on Symfony: Complete 2026 Guide
Symfony is the leading enterprise PHP framework — powering Drupal, Magento, and Laravel under the hood. Bot blocking uses Symfony's EventSubscriber system: a single PHP class that listens on two kernel events — KernelEvents::REQUEST for hard 403 blocking before any controller runs, and KernelEvents::RESPONSE for injecting the X-Robots-Tag header on every outgoing response. No extra packages required.
Four protection layers
Layer 1: robots.txt
Place robots.txt directly in the public/ directory — Symfony's document root. Any web server (Apache, Nginx, Caddy) serves it as a static file before PHP is even invoked.
# 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: /
Symfony's default public/ already contains index.php. Add robots.txt alongside it — no route definition or controller needed. Committed to version control so it deploys with your app.
Layer 2: noai meta tag
Add the tag to your Twig base template using the built-in default filter. Per-page override is handled by passing a robots variable from the controller.
Base Twig template
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My App{% endblock %}</title>
{# AI bot training opt-out. Per-page override: pass robots from controller. #}
<meta name="robots" content="{{ robots|default('noai, noimageai') }}">
{% block stylesheets %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>Controller — default (no override)
<?php
// src/Controller/HomeController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'home')]
public function index(): Response
{
// No robots key → base template defaults to "noai, noimageai"
return $this->render('home/index.html.twig');
}
}Controller — per-page override
<?php
// Public pages that should be indexed normally:
#[Route('/about', name: 'about')]
public function about(): Response
{
return $this->render('pages/about.html.twig', [
'robots' => 'index, follow',
]);
}Layers 3 & 4: EventSubscriber
A single EventSubscriber class listens on two kernel events. KernelEvents::REQUEST fires first (before routing) — used for the hard 403 block. KernelEvents::RESPONSE fires last — used to inject the X-Robots-Tag header on all responses.
<?php
// src/EventSubscriber/AiBotSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class AiBotSubscriber implements EventSubscriberInterface
{
// Case-insensitive substrings — matches partial UA strings
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',
];
// Always allow — crawlers must be able to read robots.txt
private const EXEMPT_PATHS = [
'/robots.txt',
'/sitemap.xml',
'/favicon.ico',
];
public static function getSubscribedEvents(): array
{
return [
// Layer 4: run before routing (priority 9999 > controller priority 0)
KernelEvents::REQUEST => ['onKernelRequest', 9999],
// Layer 3: add header to every response
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
// Only act on the main request, not sub-requests (e.g. Twig includes)
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
// Exempt robots.txt, sitemap, favicon — must be readable by all bots
if (in_array($path, self::EXEMPT_PATHS, true)) {
return;
}
$ua = strtolower($request->headers->get('User-Agent', ''));
foreach (self::AI_BOT_PATTERNS as $pattern) {
if (str_contains($ua, $pattern)) {
// Layer 4: hard block — short-circuit before any controller runs
$event->setResponse(new Response('Forbidden', Response::HTTP_FORBIDDEN, [
'Content-Type' => 'text/plain',
]));
return;
}
}
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
// Layer 3: inject X-Robots-Tag on every response
$event->getResponse()->headers->set('X-Robots-Tag', 'noai, noimageai');
}
}Key points
isMainRequest()guards against sub-requests (e.g. Twigrender()calls, ESI fragments) — without it, the bot check would fire multiple times per page.- Priority
9999on REQUEST ensures the subscriber runs before Symfony's Router (priority 32) and Security (priority 8). Controllers run at priority0. $event→setResponse()short-circuits the kernel — no further REQUEST listeners fire and no controller is invoked.str_contains()is PHP 8.0+. For PHP 7.x, usestrpos($ua, $pattern) !== false.- RESPONSE listener runs after the controller — it adds the header to the already-built response, including error responses and redirects.
Service registration
With autoconfigure: true (Symfony default since 3.4), any class implementing EventSubscriberInterface is automatically tagged and registered. No manual configuration needed.
Automatic (autoconfigure)
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true # ← automatically tags EventSubscriberInterface implementations
public: false
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'Manual (if autoconfigure is off)
# config/services.yaml
services:
App\EventSubscriber\AiBotSubscriber:
tags:
- { name: kernel.event_subscriber }Environment-specific disabling
To disable the subscriber in the dev environment only:
# config/services.yaml
when@dev:
services:
App\EventSubscriber\AiBotSubscriber:
autoconfigure: false
tags: []Verification
# Layer 1 — robots.txt served as static file curl -I https://yoursite.com/robots.txt # Layer 3 — X-Robots-Tag header on a real page curl -I https://yoursite.com/ # Layer 4 — hard 403 when GPTBot user-agent is sent curl -A "GPTBot" -I https://yoursite.com/ # Expected: HTTP/1.1 403 Forbidden # Verify robots.txt exempt from hard block curl -A "GPTBot" -I https://yoursite.com/robots.txt # Expected: HTTP/1.1 200 OK
Unit testing the subscriber
<?php
// tests/EventSubscriber/AiBotSubscriberTest.php
use App\EventSubscriber\AiBotSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class AiBotSubscriberTest extends TestCase
{
private function makeEvent(string $ua, string $path = '/'): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create($path);
$request->headers->set('User-Agent', $ua);
return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
}
public function testGptBotIsBlocked(): void
{
$event = $this->makeEvent('Mozilla/5.0 (compatible; GPTBot/1.0)');
(new AiBotSubscriber())->onKernelRequest($event);
$this->assertEquals(403, $event->getResponse()->getStatusCode());
}
public function testRobotsTxtIsExempt(): void
{
$event = $this->makeEvent('GPTBot', '/robots.txt');
(new AiBotSubscriber())->onKernelRequest($event);
$this->assertNull($event->getResponse()); // No response set → request proceeds normally
}
public function testLegitimateUserPassesThrough(): void
{
$event = $this->makeEvent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
(new AiBotSubscriber())->onKernelRequest($event);
$this->assertNull($event->getResponse());
}
}FAQ
Should I use KernelEvents::REQUEST or a Symfony firewall for AI bot blocking?
Use KernelEvents::REQUEST in an EventSubscriber. The Security firewall is designed for authentication — it requires user providers, tokens, and passport logic that add complexity with no benefit for simple user-agent matching. The EventSubscriber approach is framework-idiomatic, requires no extra configuration, and is easy to unit test without standing up a full kernel.
What priority should I use for the KernelEvents::REQUEST listener?
Use 9999. Symfony dispatches REQUEST listeners from highest to lowest priority. Controllers run at priority 0. The Router listener runs at priority 32. Priority 9999 ensures the bot check fires before routing, security checks, and any controller logic — blocked requests never touch your database or business logic.
Do I need to manually tag the EventSubscriber as kernel.event_subscriber?
No — with autoconfigure: true (the Symfony default since 3.4), any class implementing EventSubscriberInterface is automatically tagged. Symfony reads getSubscribedEvents() at compile time and registers the listeners. If you see no effect after creating the subscriber, run php bin/console debug:event-dispatcher kernel.request to verify it appears in the listener list.
How do I override the noai meta tag per page in Twig?
Pass a robots variable from your controller: return $this->render("page.html.twig", ["robots" => "index, follow"]). In your Twig base template use {{ robots|default("noai, noimageai") }}. The default filter supplies the fallback when robots is absent from the template context — no Twig extension required.
Will this EventSubscriber affect Symfony's profiler or debug toolbar?
No — the Symfony Profiler runs at the /_profiler path and uses internal sub-requests. The isMainRequest() check prevents the subscriber from acting on sub-requests. The Profiler's own user-agent does not match any AI bot pattern. If you test with a custom user-agent in devtools that accidentally matches a pattern, disable the subscriber in the dev environment using when@dev in services.yaml.
Is your site protected from AI bots?
Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.