llms.txt on Drupal
Three ways to serve /llms.txt from Drupal, why a file in the docroot can vanish on your next Composer deploy, and the .htaccess rule that blocks it on some hosts.
Drupal sits behind a front controller in the docroot (web/ or docroot/). A real file there is served directly; anything generated needs a route.
Serving /llms.txt from Drupal.
The quick way: a file in the docroot
Drupal's docroot is web/ on a Composer-managed site and docroot/ on some hosting platforms - the directory containing index.php and core/. A file placed there is served by the web server without Drupal bootstrapping at all.
your-project/
composer.json
vendor/
web/ ← the docroot
core/
modules/
index.php
robots.txt
llms.txt ← here, beside robots.txtThe durable way: a route in a custom module
A route survives deploys, lives in version control, and can build the file from your actual content. This is the approach to use on anything managed by Composer, because the docroot is partly rebuilt on deploy.
acme_llms.txt:
path: '/llms.txt'
defaults:
_controller: '\Drupal\acme_llms\Controller\LlmsTxtController::build'
requirements:
_access: 'TRUE'The controller that renders it
Return a plain Response, not a render array - a render array gets wrapped in your theme, which is how a correct implementation ends up serving HTML. Set the content type explicitly and add cache metadata so Drupal's page cache and any reverse proxy treat it properly.
<?php
namespace Drupal\acme_llms\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Response;
class LlmsTxtController extends ControllerBase {
public function build(): Response {
$lines = [
'# ' . $this->config('system.site')->get('name'),
'',
'> ' . $this->config('system.site')->get('slogan'),
'',
'## Docs',
'',
];
// Build the link list from your own content query here.
$response = new Response(implode("\n", $lines) . "\n");
$response->headers->set('Content-Type', 'text/plain; charset=utf-8');
return $response;
}
}What Drupal gets wrong.
The failures below are specific to Drupal. A generic llms.txt guide will not mention any of them.
Composer rebuilds the docroot and drops your file
The Drupal-specific trap. On a Composer-managed site, drupal/core-composer-scaffold owns files in the docroot and re-creates them on install and update. A hand-placed llms.txt is not tracked by the scaffold, so depending on your deploy pipeline it either survives by luck or disappears on the next build. If you want a file there, add it to the scaffold's file-mapping in composer.json so it is written deliberately.
{
"extra": {
"drupal-scaffold": {
"file-mapping": {
"[web-root]/llms.txt": "assets/llms.txt"
}
}
}
}A render array instead of a Response
Returning an array from the controller hands the output to Drupal's render pipeline, which wraps it in the active theme. The response is then a full HTML page served at /llms.txt with a 200 - valid-looking to anything that only checks the status code, and useless to anything that reads the body.
.htaccess rules that block unfamiliar extensions
Drupal ships a fairly strict .htaccess, and hardened hosting adds to it. Some configurations only allow a known list of static file extensions through to the filesystem. If your file is definitely in the docroot and still 404s, check .htaccess before assuming Drupal is at fault.
The internal page cache serving a stale copy
With Internal Page Cache enabled, a route-based llms.txt is cached like any other anonymous response. After changing the content, flush caches - otherwise you are validating yesterday's file.
Next to read.
Questions, answered.
Where do I put llms.txt in Drupal?
Is there a Drupal module for llms.txt?
Why does my Drupal llms.txt render with my theme around it?
Does llms.txt need to be in the Drupal sitemap?
An llms.txt will not fix a site AI cannot read.
Before writing an index for AI clients, check that they can fetch your pages at all. LintPage runs 60 checks against a URL in about 30 seconds - free, no signup.
run a full scan →