soleinjast / symfony-markdown-response-bundle
Serve markdown versions of your HTML pages to AI agents and bots
Package info
github.com/soleinjast/symfony-markdown-response-bundle
Type:symfony-bundle
pkg:composer/soleinjast/symfony-markdown-response-bundle
Requires
- php: ^8.2
- league/html-to-markdown: ^5.1
- psr/log: ^1|^2|^3
- symfony/config: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
Requires (Dev)
- phpstan/phpstan: ^2.3
- phpstan/phpstan-phpunit: ^2.1
- phpstan/phpstan-symfony: ^2.1
- phpunit/phpunit: ^11.0
- rector/rector: ^2.3
- symfony/cache: ^6.4|^7.0|^8.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
- symfony/http-client: ^6.4|^7.0|^8.0
- symfony/phpunit-bridge: ^7.0|^8.0
Suggests
- symfony/cache: For caching converted markdown responses
- symfony/http-client: Required when using the cloudflare driver
Provides
None
Conflicts
None
Replaces
None
README
Introduction
A Symfony bundle that automatically serves Markdown versions of your HTML pages to AI agents, bots, and any client that requests it. Controllers opt in with a single PHP attribute — the bundle handles detection, conversion, and caching transparently.
Why
AI assistants (ChatGPT, Claude, Perplexity, etc.) and autonomous agents consume web content better as plain Markdown than as HTML. Stripping navigation, scripts, styles, and markup noise reduces token usage and improves comprehension — without maintaining a separate content pipeline.
Installation
composer require soleinjast/symfony-markdown-response-bundle
If you are not using Symfony Flex, register the bundle manually:
// config/bundles.php return [ // ... Soleinjast\SymfonyMarkdownResponseBundle\SymfonyMarkdownResponseBundle::class => ['all' => true], ];
Quick Start
1. Opt a controller in
Apply the #[ProvideMarkdownResponse] attribute to a controller class or to individual action methods.
use Soleinjast\SymfonyMarkdownResponseBundle\Attribute\ProvideMarkdownResponse; #[ProvideMarkdownResponse] class DocsController extends AbstractController { public function index(): Response { /* ... */ } public function show(): Response { /* ... */ } }
Or enable it per-method and disable it on specific actions:
#[ProvideMarkdownResponse] class BlogController extends AbstractController { public function index(): Response { /* ... */ } #[ProvideMarkdownResponse(enabled: false)] public function edit(): Response { /* only humans edit posts */ } }
2. That's it
The bundle detects AI clients automatically. No changes to routes, templates, or responses are required.
How Detection Works
A request is considered to want Markdown if any of the following is true:
| Signal | Example |
|---|---|
Accept header prefers text/markdown |
Accept: text/markdown, */* |
.md URL suffix |
GET /docs/setup.md |
| Known AI User-Agent substring | User-Agent: GPTBot/1.0 |
_wants_markdown request attribute |
Set internally after .md rewrite |
The Accept header honours quality values: Markdown is served only when text/markdown has a q above 0 and at least as high as text/html. For example, text/html, text/markdown;q=0.5 gets HTML and text/markdown;q=0 never gets Markdown. Wildcards such as */* do not count as asking for Markdown.
The .md suffix is stripped before routing so your route definitions remain unchanged. It only works on controllers that opted in: a .md request that resolves to any other controller returns 404 Not Found, so the rest of your site does not get duplicate .md URLs serving HTML. The _wants_markdown attribute is set on the request to carry the intent forward to the response phase.
User-Agent patterns are matched case-insensitively as substrings. The defaults cover:
| Operator | Patterns |
|---|---|
| OpenAI | GPTBot, OAI-SearchBot, ChatGPT-User |
| Anthropic | ClaudeBot, Claude-SearchBot, Claude-User, anthropic-ai, Claude-Web |
| Perplexity | PerplexityBot, Perplexity-User |
| Meta | meta-externalagent, meta-externalfetcher |
| Mistral | MistralAI-User |
| DuckDuckGo | DuckAssistBot |
| Amazon | Amazonbot |
| ByteDance | Bytespider |
| Common Crawl | CCBot |
Google-Extended and Applebot-Extended are not included: they are robots.txt tokens only and never appear in a User-Agent header. Regular search engines (Googlebot, Bingbot, Applebot) are not matched.
Configuration
Publish the configuration file (optional — all keys have defaults):
# config/packages/symfony_markdown_response.yaml symfony_markdown_response: driver: local # "local" or "cloudflare" cloudflare_endpoint: ~ # required when driver is "cloudflare" cloudflare_timeout: 5 # seconds cloudflare_fallback: true # use the local driver if Cloudflare fails cache_enabled: true cache_ttl: 3600 # seconds cache_service: ~ # PSR-6 service ID; defaults to cache.app ai_user_agents: # replaces the default list (see "How Detection Works") - GPTBot - ClaudeBot - PerplexityBot # ...
Configuration reference
| Key | Type | Default | Description |
|---|---|---|---|
driver |
local|cloudflare |
local |
Conversion backend |
cloudflare_endpoint |
string|null |
null |
Cloudflare Workers AI URL (required for cloudflare driver) |
cloudflare_timeout |
float |
5 |
Seconds to wait for the Cloudflare endpoint before giving up |
cloudflare_fallback |
bool |
true |
Convert with the local driver when the Cloudflare endpoint fails or times out |
cache_enabled |
bool |
true |
Cache converted Markdown |
cache_ttl |
int |
3600 |
Cache TTL in seconds |
cache_service |
string|null |
null |
PSR-6 cache pool service ID; falls back to cache.app then cache.system |
ai_user_agents |
string[] |
(see above) | User-Agent substrings (case-insensitive) that trigger Markdown responses. Setting it replaces the defaults |
Conversion Drivers
local (default)
Uses league/html-to-markdown. Conversion happens in-process with no external dependencies. The following HTML nodes are stripped before conversion: head, script, style, nav, footer, aside.
cloudflare
Posts the HTML to a Cloudflare Workers AI endpoint and returns the response body as Markdown. Requires symfony/http-client and a configured endpoint URL.
symfony_markdown_response: driver: cloudflare cloudflare_endpoint: 'https://your-worker.example.workers.dev/to-markdown'
If the endpoint returns an error, cannot be reached, or does not answer within cloudflare_timeout seconds, the bundle logs a warning (on the markdown_response channel when Monolog is installed) and converts the page with the local driver instead, so agents still receive Markdown. Set cloudflare_fallback: false to let the error propagate instead, for example while setting up the Worker.
Caching
Converted Markdown is cached by default using a PSR-6 cache pool. The cache key is derived from an xxh3 hash of the pre-processed HTML, so distinct page content produces distinct cache entries.
Resolution order for the cache pool:
cache_serviceif explicitly configuredcache.appif present in the containercache.systemif present in the container- No caching (conversion runs on every request)
To disable caching entirely:
symfony_markdown_response: cache_enabled: false
HTTP caches and CDNs
Because the same URL can return HTML or Markdown depending on the Accept and User-Agent request headers, responses from opted-in controllers carry Vary: Accept, User-Agent (merged with any existing Vary values). This stops shared caches such as Varnish or Symfony's HttpCache from serving the Markdown variant to browsers, or the HTML variant to agents. Requests using the .md suffix are not affected, since their URL is already distinct.
Some CDNs ignore Vary for HTML responses (Cloudflare, for example). If you cache pages at the edge, either bypass the cache for AI user agents and Accept: text/markdown requests, or point agents to the .md URLs.
Preprocessing Pipeline
You can strip or transform HTML before conversion by implementing HtmlPreprocessorInterface. Services implementing this interface are auto-configured via the symfony_markdown_response.html_preprocessor tag.
use Soleinjast\SymfonyMarkdownResponseBundle\Converter\HtmlPreprocessorInterface; class RemoveCookieBannerPreprocessor implements HtmlPreprocessorInterface { public function process(string $html): string { // Remove cookie consent banners, ads, or any other noise return preg_replace('/<div[^>]+class="[^"]*cookie[^"]*".*?<\/div>/si', '', $html); } }
Register it as a service (or rely on autowiring/autoconfiguration):
# config/services.yaml App\Preprocessor\RemoveCookieBannerPreprocessor: tags: - { name: symfony_markdown_response.html_preprocessor }
Multiple preprocessors are applied in the order they are resolved from the container.
Accessing Markdown Programmatically
The MarkdownConverter service is available for injection if you need to convert HTML outside of the HTTP cycle:
use Soleinjast\SymfonyMarkdownResponseBundle\Converter\MarkdownConverter; class SitemapExporter { public function __construct(private readonly MarkdownConverter $converter) {} public function export(string $html): string { return $this->converter->convert($html); } }
Requesting Markdown as a Client
There are three ways to request a Markdown response:
1. URL suffix
GET /docs/getting-started.md HTTP/1.1
2. Accept header
GET /docs/getting-started HTTP/1.1
Accept: text/markdown
3. Known AI User-Agent (automatic — no client changes required)
GET /docs/getting-started HTTP/1.1
User-Agent: GPTBot/1.0
All three methods produce a response with Content-Type: text/markdown; charset=UTF-8.
Contributing
Contributions are welcome. Please open an issue before submitting a pull request for significant changes so the approach can be discussed first.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Write tests for your changes
- Submit a pull request
License
Released under the MIT License.
Happy coding! 🎉
