---
title: "Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla"
description: "Content negotiation text/markdown on-origin senza Cloudflare: servire markdown a Claude, Cursor e OpenCode da Laravel 12, Symfony 7 e PHP vanilla con RFC 9110."
canonical: "https://www.mauriziofonte.it/blog/post/content-negotiation-text-markdown-senza-cloudflare-laravel-symfony-php-vanilla.html"
author: "Maurizio Fonte"
date: "2026-04-30T08:15:30+02:00"
modified: "2026-06-19T10:51:42+02:00"
section: "API Design"
tags: ["API Design", "Architettura Software"]
license: "Riuso e citazione consentiti con attribuzione e link alla fonte"
---

[API Design](https://www.mauriziofonte.it/blog/cat/api-design.html) [Architettura Software](https://www.mauriziofonte.it/blog/cat/architettura_software.html)

# [Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla](https://www.mauriziofonte.it/blog/post/content-negotiation-text-markdown-senza-cloudflare-laravel-symfony-php-vanilla.html)

Giovedì 30 Aprile 2026, alle 08:15 ![Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla](https://www.mauriziofonte.it/image/004120200-content-negotiation-text-markdown-senza-cloudflare-laravel-symfony-php-vanilla.jpg)

Il 16 febbraio 2026, quattro giorni dopo il lancio di "Markdown for Agents" da parte di Cloudflare, ho passato una domenica mattina a misurare il traffico del mio blog sandbox ospitato su un Hetzner CCX33 (8 vCPU AMD EPYC 9454P, 32 GB RAM DDR5, 240 GB NVMe) con stack Laravel 12, PHP-FPM 8.3, Nginx 1.26 e Redis 7.2. L'obiettivo era capire chi davvero mandasse `Accept: text/markdown` nell'header HTTP e quanto mi costasse in token, se quegli agenti fossero miei (scraper che popolano una mia pipeline di knowledge management). Sullo stesso articolo tecnico da 2.400 parole ho misurato 16.180 token in HTML (count `cl100k_base` via `tiktoken` Python) e 3.150 token in Markdown pulito dopo preprocessing. L'ottanta percento di riduzione non è un'iperbole di marketing di Cloudflare: è la matematica di un `<h2 class="section-title" id="about">About Us</h2>` che pesa 12-15 token quando l'equivalente `## About Us` ne pesa 3.

Il blog post Cloudflare del 12 febbraio 2026 documenta esattamente lo stesso rapporto usando il proprio articolo come benchmark. La notizia vera, però, non è che Cloudflare converta l'HTML in Markdown all'edge per te. La notizia è che il pattern sottostante è **content negotiation standard RFC 9110**, un meccanismo documentato e pubblico dal 1996, e che puoi implementarlo direttamente sul tuo backend Laravel, Symfony o PHP vanilla senza dipendere da un edge provider. Se sei su Aruba, OVH o un server non instradato da Cloudflare, se vuoi mantenere il controllo del `Content-Signal` e della logica di cache, o se semplicemente rifiuti il lock-in di "un'altra feature gestita dal proxy", questo articolo ti mostra come farlo. Nessuna magia, nessun toggle nel dashboard di qualcun altro. Solo `Accept: text/markdown`, `Vary: Accept`, e sessanta righe di middleware scritte bene.

## Perché gli agenti AI stanno chiedendo Markdown al tuo web server?

La risposta breve è: perché pagano ogni token di input processato, e HTML è pieno di token inutili. La risposta lunga richiede due dati e una distinzione.

Primo dato. Il report Cloudflare di giugno 2025 ha misurato il crawl-to-referral ratio dei principali AI bot. Google rimanda una visita ogni 14 crawl. OpenAI una ogni 1.700. **Anthropic una ogni 73.000**. Il deal storico "mi scansioni, mi mandi traffico" è quantitativamente rotto nel caso AI: gli agenti usano il contenuto per rispondere dentro la propria interfaccia, non per redirigerlo a te. Secondo dato. Da novembre 2025 gli agenti di coding production-grade (Claude Code, Cursor, OpenCode) inviano di default header HTTP che includono `text/markdown` nella lista dei media type accettati, ordinato prima di `text/html` con quality factor 1.0. Non lo fanno perché Cloudflare glielo ha chiesto. Lo fanno perché la loro pipeline di context ingestion risparmia fino all'ottanta percento di token quando riceve Markdown, e quel risparmio si traduce in una bolletta Anthropic che scende di oltre metà a parità di chiamate.

La distinzione importante riguarda chi sei tu. Se sei il publisher di un blog letto da umani, il tuo business non cambia: gli agenti continuerebbero a scansionarti anche senza Markdown. Se sei una PMI che **integra** agenti nel proprio stack interno (quindi i miei clienti reali, e me stesso nella mia pipeline personale di automazione AI), allora ogni scansione che i tuoi agenti fanno verso i tuoi asset di documentazione è un costo API che puoi tagliare di un ordine di grandezza. Non è SEO. Non è AEO. È cost governance sull'input di contesto. Guardando il mio dashboard Langfuse delle ultime quattro settimane, i soli retrieval da documentazione interna hanno consumato 2,8 milioni di token di input. Sostituire HTML con Markdown li porta a 560 mila. A $3 per milione su Claude Sonnet 4.6, sono $6,72 al mese di risparmio su un singolo progetto giocattolo. Su un RAG aziendale con corpus più grosso, i conti si moltiplicano.

Se vuoi vedere come strutturo pipeline AI di produzione integrando governance dei costi, retrieval tokenizzato ed engineering dei confini operativi degli agenti, nel [mio hub dedicato all'AI per aziende](https://www.mauriziofonte.it/ai-aziende/ai-aziende-integrazione.html) trovi articoli tecnici con metodologia applicata e perimetro dichiarato.

## Cosa dice davvero RFC 9110 sulla content negotiation

Il documento di riferimento è [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110), pubblicato nel giugno 2022 come stabilizzazione di quindici anni di draft. La sezione 12 descrive due modalità di negoziazione del contenuto.

La prima è **proactive (server-driven) negotiation**, descritta in §12.1. Il client dichiara le proprie preferenze in header di richiesta (tipicamente `Accept`, `Accept-Language`, `Accept-Encoding`) e il server sceglie la rappresentazione migliore disponibile. La sintassi di `Accept` è definita in §12.5.1 e consente q-values per esprimere preferenze relative: `Accept: text/markdown;q=1.0, text/html;q=0.9` significa "preferisco Markdown, accetto HTML se Markdown non è disponibile". La MIME type `text/markdown` è formalizzata da [RFC 7763](https://www.rfc-editor.org/rfc/rfc7763) del 2016.

La seconda modalità è **reactive (agent-driven) negotiation**, §12.2, dove il server risponde con 300 Multiple Choices fornendo una lista di varianti e il client sceglie. Praticamente inutilizzata sul web moderno. La ignoriamo.

Due header lato server sono critici. `Vary: Accept` nella risposta dichiara che la rappresentazione dipende dall'header `Accept` della richiesta: ogni cache intermedia (CDN, proxy, browser) **deve** considerare quell'header parte della chiave di cache, altrimenti servirebbe Markdown a un utente o HTML a un bot. `Content-Type: text/markdown; charset=utf-8` identifica la rappresentazione servita. Lo status code 406 Not Acceptable si usa quando la risorsa non è disponibile in nessuno dei media type accettati dal client; nella pratica, è raro che sia necessario, perché tutti gli agenti accettano anche `text/html` come fallback.

La ragione per cui questo non è **cloaking** è topologica. Cloaking significa servire URL diversi (o contenuti divergenti allo stesso URL, con la stessa rappresentazione contrattuale) a bot e utenti, tipicamente basandosi sullo User-Agent. Content negotiation significa servire **la stessa risorsa** in una rappresentazione diversa dichiarata dall'header del client. È HTTP da manuale. Il mittente del `Accept: text/markdown` sa cosa sta chiedendo; chi non lo manda riceve la rappresentazione default HTML.

## Lo stato dell'arte al 23 aprile 2026

Chi manda davvero `Accept: text/markdown`? Ho verificato su un pool di 47 giorni di access log del mio blog. La situazione è questa:

| Agent | Version | Manda text/markdown? |
| --- | --- | --- |
| Claude Code | 2.1.38 | Sì (q=1.0) |
| Cursor | 2.4.28 | Sì (q=1.0) |
| OpenCode | 1.2.5 | Sì (q=1.0) |
| Claude Agent SDK (fetch tool) | 0.4.x | Sì |
| GitHub Copilot Chat | 1.18 | No (solo text/html) |
| Gemini CLI | 0.9.4 | No |
| OpenAI Codex CLI | 0.8 | No |
| ChatGPT plugin retrieval | n/a | No |
| Perplexity browser | n/a | No |

Questo è il panorama agenti. Il panorama publisher è più affollato. Da settembre 2025 sette publisher tecnici servono Markdown via content negotiation direttamente on-origin senza Cloudflare: Anthropic docs, Stripe docs, Shopify engineering, Vercel, Mintlify, Redis docs, Qdrant docs. [Cloudflare ha aggiunto l'edge conversion il 12 febbraio 2026](https://blog.cloudflare.com/markdown-for-agents/) abilitando la feature di default per Pro, Business ed Enterprise. Quattro giorni prima, il 6 febbraio 2026, John Mueller di Google aveva detto pubblicamente che la pratica di creare pagine Markdown separate per LLM era "a stupid idea", posizione ripresa da Fabrice Canel di Bing ([fonte](https://searchengineland.com/google-bing-dont-recommend-seperate-markdown-pages-for-llms-468365)). Cloudflare ha scelto il momento.

## Tre approcci per servire Markdown on-origin

Hai tre strade, con trade-off diversi di dipendenza, manutenzione e controllo.

La prima è **usare un pacchetto pronto**. Spatie ha rilasciato il 17 febbraio 2026 [`spatie/laravel-markdown-response`](https://github.com/spatie/laravel-markdown-response) (v1.1.0 il 22 febbraio), che richiede PHP 8.4 e Laravel 12, implementa un middleware `ProvideMarkdownResponse`, detecta richieste via header `Accept`, user-agent noti (GPTBot, ClaudeBot) e suffisso `.md` nell'URL. Usa `league/html-to-markdown` 5.1+ come driver locale, opzionalmente Cloudflare Workers AI come driver remoto. Le risposte convertite sono cacheate di default. Per Symfony l'equivalente è [`soleinjast/symfony-markdown-response-bundle`](https://github.com/soleinjast/symfony-markdown-response-bundle) v1.0.0 del 20 febbraio 2026, richiede PHP 8.2+, Symfony 6.4/7.x/8.x, espone l'attributo `#[ProvideMarkdownResponse]` applicabile a classe controller o metodo, usa `Vary: Accept` e un'interfaccia `HtmlPreprocessorInterface` per rimuovere rumore (cookie banner, navbar) prima della conversione.

La seconda strada è **custom middleware zero-dep**. Niente Spatie, niente bundle. Quaranta-sessanta righe di codice che parsano `Accept`, passano l'HTML preprocessato a `league/html-to-markdown`, cachano il risultato. Vantaggio: nessuna dipendenza esterna oltre alla libreria di conversione, controllo totale. Svantaggio: devi gestire tu edge case (multipart, content-length, cache invalidation).

La terza strada è **sidecar endpoint** tipo `/docs/{slug}/markdown.md`. La peggiore. Duplica URL, conforma esattamente alla critica di Mueller, e costringe i tuoi agenti interni a conoscere due mappe di routing. La menziono solo per scongiurarla.

Io uso l'approccio custom su tutto quello che non è blog-pubblico, perché i miei agenti sanno cosa chiedere e voglio il controllo del preprocessing. Spatie/soleinjast sui blog pubblici, perché la community mantiene le edge case.

## Implementazione Laravel 12 passo per passo

Partiamo dal custom middleware. Installi solo il converter:

```bash
composer require league/html-to-markdown:^5.1
```

Poi crei il middleware:

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use League\HTMLToMarkdown\HtmlConverter;
use Symfony\Component\HttpFoundation\Response;

class NegotiateMarkdown
{
    // TTL di cache per la rappresentazione markdown: un'ora
    private const CACHE_TTL_SECONDS = 3600;

    // Media type richiesto dagli agenti AI
    private const MARKDOWN_MIME = 'text/markdown';

    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Converto solo se: status 200, Content-Type HTML, client ha chiesto markdown
        if (! $this->shouldConvert($request, $response)) {
            return $this->withVaryHeader($response);
        }

        $html = $response->getContent();
        if ($html === false || $html === '') {
            return $this->withVaryHeader($response);
        }

        // Chiave di cache: hash del path + query + hash del body sorgente (invalida su cambio contenuto)
        $cacheKey = 'md:' . sha1($request->fullUrl() . '|' . sha1($html));

        $markdown = Cache::remember(
            $cacheKey,
            self::CACHE_TTL_SECONDS,
            fn () => $this->convert($html)
        );

        // Stima token grezza basata su 4 char/token (euristica cl100k_base)
        $estimatedTokens = (int) ceil(strlen($markdown) / 4);

        return response($markdown, 200)
            ->header('Content-Type', self::MARKDOWN_MIME . '; charset=utf-8')
            ->header('Vary', 'Accept')
            ->header('X-Markdown-Tokens', (string) $estimatedTokens)
            ->header('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes');
    }

    private function shouldConvert(Request $request, Response $response): bool
    {
        if ($response->getStatusCode() !== 200) {
            return false;
        }

        $contentType = strtolower((string) $response->headers->get('Content-Type', ''));
        if (! str_contains($contentType, 'text/html')) {
            return false;
        }

        return $request->accepts(self::MARKDOWN_MIME);
    }

    private function withVaryHeader(Response $response): Response
    {
        // Vary: Accept SEMPRE, anche quando servo HTML, altrimenti la cache edge serve
        // la risposta sbagliata al client successivo con Accept diverso
        $response->headers->set('Vary', 'Accept', false);

        return $response;
    }

    private function convert(string $html): string
    {
        // Preprocessing: rimuovo rumore strutturale prima della conversione
        $cleaned = $this->stripNoise($html);

        $converter = new HtmlConverter([
            'strip_tags' => true,
            'remove_nodes' => 'script style nav aside footer',
            'hard_break' => true,
            'header_style' => 'atx',
        ]);

        return trim($converter->convert($cleaned));
    }

    private function stripNoise(string $html): string
    {
        // Rimuovo blocchi tipici non-content via regex conservativa
        $patterns = [
            '#<script[^>]*>.*?</script>#si',
            '#<style[^>]*>.*?</style>#si',
            '#<!--(?!\[if).*?-->#s',
        ];

        return preg_replace($patterns, '', $html) ?? $html;
    }
}
```

Registri il middleware in `bootstrap/app.php` con alias `negotiate.markdown` e lo applichi al gruppo di route che vuoi esporre:

```php
use App\Http\Middleware\NegotiateMarkdown;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'negotiate.markdown' => NegotiateMarkdown::class,
        ]);
    })
    ->create();
```

Route group:

```php
Route::middleware(['negotiate.markdown'])->group(function () {
    Route::get('/blog/post/{slug}', [BlogController::class, 'show']);
    Route::get('/docs/{page}', [DocsController::class, 'show']);
});
```

Test diretto:

```bash
# Richiesta HTML classica
curl -I https://example.test/blog/post/foo
# Content-Type: text/html; charset=UTF-8
# Vary: Accept

# Richiesta Markdown
curl -H 'Accept: text/markdown' https://example.test/blog/post/foo
# Content-Type: text/markdown; charset=utf-8
# X-Markdown-Tokens: 842
```

Il middleware rispetta il principio di singola responsabilità: converte quando richiesto, propaga `Vary: Accept` sempre (anche sulle risposte HTML, per non rompere le cache edge), non tocca il controller che continua a renderizzare HTML Blade come ha sempre fatto.

## Implementazione Symfony 7.2 passo per passo

Stesso pattern, traduzione idiomatica. Install:

```bash
composer require league/html-to-markdown:^5.1
```

Event subscriber che intercetta `kernel.response`:

```php
<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use League\HTMLToMarkdown\HtmlConverter;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\AcceptHeader;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class MarkdownNegotiationSubscriber implements EventSubscriberInterface
{
    private const MARKDOWN_MIME = 'text/markdown';
    private const CACHE_TTL_SECONDS = 3600;

    public function __construct(
        private readonly CacheItemPoolInterface $cache,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        // Priority negativa: eseguo dopo altri subscriber che potrebbero aver già
        // impostato header di sicurezza o compressione
        return [KernelEvents::RESPONSE => ['onKernelResponse', -10]];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        $request = $event->getRequest();
        $response = $event->getResponse();

        // Vary: Accept sempre, anche quando servo HTML
        $response->setVary('Accept', false);

        if (! $this->shouldConvert($request->headers->get('Accept', ''), $response)) {
            return;
        }

        $html = $response->getContent();
        if (! is_string($html) || $html === '') {
            return;
        }

        $cacheKey = 'md_' . sha1($request->getUri() . '|' . sha1($html));
        $item = $this->cache->getItem($cacheKey);

        if (! $item->isHit()) {
            // Miss: converto e cacho
            $markdown = $this->convert($html);
            $item->set($markdown);
            $item->expiresAfter(self::CACHE_TTL_SECONDS);
            $this->cache->save($item);
        } else {
            $markdown = $item->get();
        }

        $estimatedTokens = (int) ceil(strlen($markdown) / 4);

        // Sovrascrivo completamente la risposta mantenendo status e vary
        $response->setContent($markdown);
        $response->headers->set('Content-Type', self::MARKDOWN_MIME . '; charset=utf-8');
        $response->headers->set('X-Markdown-Tokens', (string) $estimatedTokens);
        $response->headers->set('Content-Signal', 'ai-train=yes, search=yes, ai-input=yes');
    }

    private function shouldConvert(string $acceptHeader, Response $response): bool
    {
        if ($response->getStatusCode() !== Response::HTTP_OK) {
            return false;
        }

        $contentType = strtolower((string) $response->headers->get('Content-Type', ''));
        if (! str_contains($contentType, 'text/html')) {
            return false;
        }

        // Parse dell'header Accept via componente nativo Symfony
        $accept = AcceptHeader::fromString($acceptHeader);

        return $accept->has(self::MARKDOWN_MIME);
    }

    private function convert(string $html): string
    {
        $converter = new HtmlConverter([
            'strip_tags' => true,
            'remove_nodes' => 'script style nav aside footer',
            'hard_break' => true,
            'header_style' => 'atx',
        ]);

        // Pulizia minima pre-conversione
        $cleaned = preg_replace(
            ['#<script[^>]*>.*?</script>#si', '#<style[^>]*>.*?</style>#si'],
            '',
            $html,
        ) ?? $html;

        return trim($converter->convert($cleaned));
    }
}
```

Registrazione via autowiring in `config/services.yaml`: nessuna configurazione aggiuntiva se hai `autoconfigure: true`. Il subscriber viene trovato automaticamente.

`Symfony\Component\HttpFoundation\AcceptHeader::fromString` è la strada idiomatica: parsa q-values e gestisce edge case come `*/*;q=0.5` (che accetta tutto con bassa priorità). Usarlo vince su un `str_contains` nell'header grezzo.

## Implementazione PHP vanilla (nessun framework)

Il pattern vale anche senza framework. Utile se stai esponendo documentazione statica servita da un front controller minimale o se hai legacy PHP 8.3 che non vuoi portare su Laravel solo per questo. Install del converter:

```bash
composer require league/html-to-markdown:^5.1
```

Helper minimale (salva come `lib/MarkdownNegotiator.php`):

```php
<?php

declare(strict_types=1);

namespace App\Lib;

use League\HTMLToMarkdown\HtmlConverter;

final class MarkdownNegotiator
{
    private const MARKDOWN_MIME = 'text/markdown';
    private const CACHE_DIR = __DIR__ . '/../var/cache/markdown';
    private const CACHE_TTL_SECONDS = 3600;

    public static function clientWantsMarkdown(string $acceptHeader): bool
    {
        // Parsing minimale: splitto su virgola e verifico presenza di text/markdown
        // con q-value >= 0.1 (scarto quelli con q=0 esplicito)
        if ($acceptHeader === '') {
            return false;
        }

        foreach (explode(',', $acceptHeader) as $part) {
            $segments = array_map('trim', explode(';', $part));
            $mime = strtolower(array_shift($segments) ?? '');

            if ($mime !== self::MARKDOWN_MIME) {
                continue;
            }

            $quality = 1.0;
            foreach ($segments as $param) {
                if (str_starts_with($param, 'q=')) {
                    $quality = (float) substr($param, 2);
                }
            }

            return $quality > 0.0;
        }

        return false;
    }

    public static function convertWithCache(string $url, string $html): string
    {
        if (! is_dir(self::CACHE_DIR)) {
            mkdir(self::CACHE_DIR, 0775, true);
        }

        $cacheFile = self::CACHE_DIR . '/' . sha1($url . '|' . sha1($html)) . '.md';

        // Cache hit valido entro TTL
        if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < self::CACHE_TTL_SECONDS) {
            return (string) file_get_contents($cacheFile);
        }

        $converter = new HtmlConverter([
            'strip_tags' => true,
            'remove_nodes' => 'script style nav aside footer',
            'hard_break' => true,
            'header_style' => 'atx',
        ]);

        $cleaned = preg_replace(
            ['#<script[^>]*>.*?</script>#si', '#<style[^>]*>.*?</style>#si'],
            '',
            $html,
        ) ?? $html;

        $markdown = trim($converter->convert($cleaned));

        // Scrittura atomica: temp file + rename per evitare lettura parziale sotto carico
        $tmp = $cacheFile . '.tmp';
        file_put_contents($tmp, $markdown);
        rename($tmp, $cacheFile);

        return $markdown;
    }
}
```

Front controller (`public/index.php`):

```php
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Lib\MarkdownNegotiator;

// Vary: Accept sempre. Fondamentale per la correttezza della cache intermedia.
header('Vary: Accept');

// Rendering HTML "classico" del sito (funzione tua, qui stubbata)
ob_start();
require __DIR__ . '/../views/home.php';
$html = (string) ob_get_clean();

$acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';

if (! MarkdownNegotiator::clientWantsMarkdown($acceptHeader)) {
    // Default: servo HTML
    header('Content-Type: text/html; charset=utf-8');
    echo $html;
    exit;
}

$fullUrl = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ($_SERVER['REQUEST_URI'] ?? '/');
$markdown = MarkdownNegotiator::convertWithCache($fullUrl, $html);

$estimatedTokens = (int) ceil(strlen($markdown) / 4);

header('Content-Type: text/markdown; charset=utf-8');
header('X-Markdown-Tokens: ' . $estimatedTokens);
header('Content-Signal: ai-train=yes, search=yes, ai-input=yes');

echo $markdown;
```

Non è un framework, non ci sono magic service container, ma la semantica è identica ai due implementation framework. Per siti legacy PHP 8.3 con front controller semplice è l'opzione a minor cost di integrazione.

## Cache differenziata tra browser e bot

Il bug più frequente in questo setup non è la conversione. È la cache. Se hai Nginx davanti al tuo PHP-FPM e stai usando `proxy_cache`, la chiave di cache di default è basata su host, path e query string. Non sull'header `Accept`. Conseguenza: il primo client che arriva decide cosa tutti gli altri riceveranno dal proxy. Se un Claude Code bussa per primo, Chrome vede Markdown. Se un Chrome bussa per primo, Claude Code vede HTML e consuma il quadruplo dei token.

La fix è cambiare la chiave di cache Nginx includendo l'header Accept:

```nginx
proxy_cache_path /var/cache/nginx/zones keys_zone=md_zone:100m inactive=1h use_temp_path=off;

map $http_accept $accept_cache_key {
    default                    "html";
    "~*text/markdown"          "markdown";
}

server {
    location / {
        proxy_cache md_zone;
        proxy_cache_key "$host$uri$is_args$args|$accept_cache_key";
        proxy_cache_valid 200 10m;
        proxy_pass http://php_fpm_backend;
    }
}
```

Due varianti per URL, non di più. `Vary: Accept` grezzo renderebbe la cache key dipendente dal valore completo dell'header, che varia molto (browser Chrome manda un `Accept` diverso da Firefox), quindi l'hit ratio crollerebbe. La mapping `html`/`markdown` normalizza a due categorie, tagliando la combinatoria.

Su Fastly VCL il pattern è simile: `hash_data(req.http.Accept ~ "text/markdown" ? "md" : "html")` dentro `vcl_hash`. Su Cloudflare Page Rules l'equivalente è la cache key personalizzata disponibile sui piani Enterprise. Se sei su piani inferiori e vuoi mantenere Cloudflare come edge ma gestire tu la conversione, disabilita cache Cloudflare sulle route markdown e lasciala al tuo origin.

## AEO: Mueller, Canel e il dibattito cloaking

Questa è la sezione dove devi stare attento, perché l'AEO hype industriale si è buttato sul tema e sta producendo contenuti fuorvianti.

Il 6 febbraio 2026, quando Lily Ray ha chiesto pubblicamente se creare pagine `.md` separate per LLM fosse una buona idea, John Mueller di Google ha risposto verbatim: *"I'm not aware of anything in that regard. In my POV, LLMs have trained on, read and parsed, normal web pages since the beginning, it seems a given that they have no problems dealing with HTML. Why would they want to see a page that no user sees? And, if they check for equivalence, why not use HTML?"*. In un tono più colorito: *"Converting pages to markdown is such a stupid idea."* Fabrice Canel di Bing ha rincarato: *"really want to double crawl load? We'll crawl anyway to check similarity. Non-user versions (crawlable AJAX and like) are often neglected, broken. Less is more in SEO!"*. Entrambe le dichiarazioni sono documentate da [Search Engine Land del 6 febbraio 2026](https://searchengineland.com/google-bing-dont-recommend-seperate-markdown-pages-for-llms-468365).

La distinzione tecnica che l'industry chiacchiera non fa quasi mai è questa. Mueller e Canel stanno criticando **pagine Markdown separate a URL diverso** (es. `/blog/post/foo.md` come route distinta servita da middleware user-agent-based). Quella pratica è classic cloaking: stesso URL concettuale, contenuto divergente basato su chi sei. La content negotiation via `Accept` è diversa: **lo stesso URL** serve rappresentazioni diverse della stessa risorsa, dichiarate dal client. È HTTP da RFC 9110. Google lo usa da anni per `Accept-Language` (stesso URL, risposta localizzata) senza considerarlo cloaking.

Il consulente SEO David McSweeney ha sollevato un'obiezione legittima in questo thread: se il backend riceve l'header `Accept: text/markdown` senza stripping, un origin malizioso potrebbe iniettare contenuto diverso per il bot rispetto all'utente, creando una "shadow web for bots". La sua preoccupazione è valida, e la risposta non è "non usare content negotiation", è "non iniettare contenuto divergente nel rendering Markdown". Se la rappresentazione Markdown è il **dump semantico** dell'HTML renderizzato, non hai cloaking. Se la Markdown include sezioni fantasma invisibili agli umani, hai cloaking anche se tecnicamente stai rispettando RFC 9110.

Google Search Central ha una posizione [esplicita da dicembre 2025](https://developers.google.com/search/docs/appearance/ai-features?hl=it) sulle AI feature: *"Non sono previsti requisiti tecnici aggiuntivi"*. Tradotto: non serve Markdown per essere citato da AI Overviews o AI Mode. Se la tua motivazione per implementare content negotiation è SEO/AEO, non ne hai. Se la tua motivazione è cost governance sugli agenti che integri nel tuo stack interno, ne hai eccome, ma smetti di sentirlo come "AEO trick". Non lo è.

## Il lethal bug: audit checklist prima di aprire al pubblico

Prima di mettere in produzione, sei controlli operativi.

Primo. Il rendering Markdown deve essere **un dump fedele** dell'HTML reso pubblico, non una versione arricchita con istruzioni nascoste per agenti. Se stai tentando di iniettare un system prompt o una descrizione aggiuntiva "per gli agenti", stai costruendo cloaking.

Secondo. Il preprocessing deve rimuovere solo **rumore strutturale** (script, style, nav, footer, cookie banner), mai semantica (paragrafi, heading, immagini con alt text, link). Se la Markdown perde link che l'HTML ha, l'agente prenderà decisioni sbagliate sul contenuto linkato.

Terzo. `Vary: Accept` deve essere presente **su tutte le risposte**, non solo su quelle Markdown. Il bug classico è settare Vary solo sul branch markdown: CDN serve HTML a bot e Markdown a utenti, inversione perfetta.

Quarto. Le route che servono dati sensibili (`/admin`, `/user/{id}/private`) non vanno mai incluse nel middleware. Non è una questione tecnica, è una questione di blast radius: se il bot scrape una pagina di dashboard autenticata con sessione valida, la Markdown finisce in contesto LLM esterno.

Quinto. Il driver di conversione deve essere **deterministico**. Se usi Cloudflare Workers AI come driver remoto (opzione di `spatie/laravel-markdown-response`), stai aggiungendo una dipendenza esterna e una variabile di output. Per contesto RAG interno, io preferisco sempre `league/html-to-markdown` locale: stesso input = stesso output.

Sesto. Test di contratto. Un integration test che chiama lo stesso URL con e senza `Accept: text/markdown` e verifica che (a) il corpo semantico sia equivalente (stessi link, stessi heading), (b) `Vary: Accept` sia sempre presente, (c) lo status code sia 200 in entrambi i casi. Questo test deve vivere in CI e fallire il deploy se qualcuno cambia il middleware senza considerarlo.

## Dove porta tutto questo

L'approccio "senza Cloudflare" non è una crociata contro Cloudflare. Cloudflare ha un ottimo prodotto e il toggle è comodo. L'approccio on-origin serve quando **vuoi possedere** il pezzo di infrastruttura che espone i tuoi asset agli agenti, perché quegli asset sono core business o perché ha senso mantenere l'unica fonte di verità della conversione dentro il tuo stack. Serve quando sei su provider non instradati da Cloudflare. Serve quando vuoi controllare il `Content-Signal` con logica custom (diversa per `/docs` rispetto a `/blog`, diversa per tenant B2B rispetto a pubblico). Quando niente di questo è vero, Cloudflare è la scelta razionale.

C'è un secondo livello che questo articolo non copre e che diventa rilevante nel momento in cui gli agenti che chiedono Markdown sono agenti commerciali di altre aziende: a quel punto, la domanda non è più "come li servo efficientemente", è "come li faccio pagare per l'accesso". Il 2026 ha finalmente sbloccato lo status code 402 Payment Required fermo dal 1997, con x402, L402 e Cloudflare pay-per-crawl che stanno costruendo l'infrastruttura economica del web agentico. Lo tratto nel [pezzo complementare su HTTP 402 che esce domani](https://www.mauriziofonte.it/blog/post/http-402-payment-required-monetizzare-crawler-ai-x402-l402-laravel-symfony-php.html): servire Markdown è il passo tecnico, far pagare il Markdown è il passo economico. Una volta che il tuo backend sa parlare Markdown agli agenti, puoi decidere se monetizzare quel canale o lasciarlo aperto. Tecnicamente, la content negotiation è il prerequisito per entrambe le scelte.

Se gestisci documentazione tecnica interna, una knowledge base aziendale o un blog tecnico che alimenta il tuo RAG o quello dei tuoi clienti, e vuoi capire se implementare content negotiation Markdown on-origin ha senso nel tuo stack specifico, il [modulo di preventivo gratuito](https://www.mauriziofonte.it/pages/preventivo-gratuito.html#consulenza-ai) ti risponde in due minuti se il tuo caso rientra nel mio ambito. Sette domande, niente impegno. Nel frattempo, se vuoi approfondire come scrivo MCP server custom che consumano proprio questo tipo di endpoint Markdown, nel [mio articolo sugli MCP server personalizzati per Claude Code](https://www.mauriziofonte.it/blog/post/mcp-server-personalizzati-claude-code-workflow-aziendale-php.html) trovi il pattern architetturale completo. La content negotiation è il layer di trasporto; gli MCP server sono il layer di orchestrazione. Insieme, smettono di essere feature e diventano infrastruttura.

Dall'articolo al lavoro vero

Se il codice di cui parla questo articolo è il tuo

Sviluppo su misura, modernizzazione di codice legacy, subentro su progetti senza documentazione.

[Vedi tre interventi con i numeri](https://www.mauriziofonte.it/programmatore-php.html) Ultima modifica: Venerdì 19 Giugno 2026, alle 10:51 [« Articolo precedenteGestire molti progetti attivi con AI come co-pilota: metodo di un consulente senior per non impazzire29 Aprile 2026](https://www.mauriziofonte.it/blog/post/gestire-progetti-attivi-metodo-consulente-senior-automazione-ai.html)[Articolo successivo »Cloudflare, Coinbase e Lightning hanno appena resuscitato HTTP 402 dopo 29 anni di oblio: ecco come far pagare gli agenti AI che scrapano il tuo sito con Laravel, Symfony e PHP vanilla1 Mag 2026](https://www.mauriziofonte.it/blog/post/http-402-payment-required-monetizzare-crawler-ai-x402-l402-laravel-symfony-php.html)

### Articoli correlati a Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla

- [Cloudflare AI Crawl Control e Content Signals: governance del training crawler per il tuo blog aziendale](https://www.mauriziofonte.it/blog/post/cloudflare-ai-crawl-control-content-signals-bloccare-ai-training.html)

  Il crawl to referral ratio di Anthropic è 73.000:1, quello di OpenAI 1.700:1. Il deal storico 'contenuto in cambio di traffico' è rotto. Cloudflare · 22 Mag 2026

  ![Cloudflare AI Crawl Control e Content Signals: governance del training crawler per il tuo blog aziendale](https://www.mauriziofonte.it/image/004210070-cloudflare-ai-crawl-control-content-signals-bloccare-ai-training.jpg)
- [Cloudflare e il managed robots.txt: governare i crawler AI da publisher italiano](https://www.mauriziofonte.it/blog/post/cloudflare-managed-robots-txt-crawler-ai-publisher-italia.html)

  I crawler AI consumano il contenuto di un sito senza generare visite: per un publisher la questione è governarli, non solo bloccarli. Gli strumenti · 6 Agosto 2026

  ![Cloudflare e il managed robots.txt: governare i crawler AI da publisher italiano](https://www.mauriziofonte.it/image/005020070-cloudflare-managed-robots-txt-crawler-ai-publisher-italia.jpg)
- [Middleware Laravel 12: da Kernel.php a bootstrap/app.php con security headers, rate limiting e terminable middleware](https://www.mauriziofonte.it/blog/post/laravel-12-middleware-avanzati-sicurezza-logica-business-pmi-refactoring.html)

  Il middleware HTTP implementa il Chain of Responsibility pattern GoF, 1994 e PSR 15 PHP FIG, 2018 lo ha standardizzato. Laravel 11 PR 6188 ha · 13 Marzo 2025

  ![Middleware Laravel 12: da Kernel.php a bootstrap/app.php con security headers, rate limiting e terminable middleware](https://www.mauriziofonte.it/image/001540070-laravel-12-middleware-avanzati-sicurezza-logica-business-pmi-refactoring.jpg)
- [Migrare un gestionale PHP 5.6 a PHP 8.4 senza riscriverlo: il caso reale di un e-commerce B2B con 12 anni di codice procedurale](https://www.mauriziofonte.it/blog/post/migrare-php-56-php-8-guida-completa-applicazioni-legacy-produzione.html)

  Un e commerce B2B con 47.000 righe di PHP 5.6 procedurale, 340 chiamate mysql connect , un hosting che aveva annunciato la rimozione di PHP 5.6 · 11 Marzo 2026

  ![Migrare un gestionale PHP 5.6 a PHP 8.4 senza riscriverlo: il caso reale di un e-commerce B2B con 12 anni di codice procedurale](https://www.mauriziofonte.it/image/002320070-migrare-php-56-php-8-guida-completa-applicazioni-legacy-produzione.jpg)
- [Cloudflare blocca i crawler AI di default su tutti i piani, anche il tuo](https://www.mauriziofonte.it/blog/post/cloudflare-blocco-crawler-ai-default-free-tier-settembre-2026.html)

  Il 15 settembre 2026 Cloudflare ha cambiato i default dei suoi controlli sui crawler AI, e stavolta il cambio ha toccato anche i milioni di siti · 23 Settembre 2026

  ![Cloudflare blocca i crawler AI di default su tutti i piani, anche il tuo](https://www.mauriziofonte.it/image/005610070-cloudflare-blocco-crawler-ai-default-free-tier-settembre-2026.jpg)

---

**Programmatore PHP Torino**, **Ingegnere del Software** e **Cyber Security Specialist** con oltre 20 anni di esperienza nell'implementazione di soluzioni avanzate. Eseguo **porting di vecchi ambienti PHP** verso release più moderne (7.x o 8.x), mi occupo di **infrastrutture web sicure** e garantisco procedure di **hardening** e **gestione dei rischi** in linea con gli standard internazionali.  
Con la mia attività da *consulente freelance*, offro supporto a imprese e reparti IT che necessitano di **refactoring**, **messa in sicurezza** e **manutenzione evolutiva** di piattaforme web. Mi occupo di analisi del codice, identificazione delle vulnerabilità e implementazione di **best practice** per la protezione di dati e servizi critici. L'obiettivo è garantire un ambiente digitale resiliente, con un codice sorgente affidabile e manutenibile.

---

## Autore

**Maurizio Fonte**, ingegnere informatico (laurea 2008), consulente IT senior e backend engineer. Nel settore IT dal 2005, libero professionista con P.IVA dal 2014. Progetta e mantiene backend di produzione e pipeline AI private per aziende italiane.

Sull'AI lavora con casi di produzione misurabili, non con demo: RAG su documentazione italiana con pgvector (HNSW/IVFFlat), LLM self-hosted (Ollama, Mistral, Qwen) per data residency europea, MCP server custom in PHP, governance EU AI Act (Annex III, art. 16, FRIA) e sicurezza degli agent (OWASP LLM Top 10, prompt injection, excessive agency). I benchmark citati negli articoli sono eseguiti direttamente sull'hardware e sul corpus dell'autore.

## Perché questa fonte è attendibile

- **Esperienza diretta, non rassegna di terzi.** I contenuti nascono da lavoro di produzione e benchmark propri, con metriche misurate (es. un RAG self-hosted su 2.400 documenti che ha portato il tempo medio di risposta interno da 14 minuti a 90 secondi, su 60 query campione).
- **Verificabilità.** Ogni claim tecnico rimanda a fonti primarie (paper, CVE, documentazione ufficiale); il blog è un laboratorio di ricerca applicata pubblico.
- **Onestà metodologica.** Ogni soluzione dichiara anche quando NON va usata. "Production-grade, non demo-grade".

## Entità e contatti (verificabili)

Maurizio Fonte, Torino, Italia. P.IVA IT11396570019. REA TO-1331015 (Camera di Commercio di Torino).  
GitHub: https://github.com/mauriziofonte  
LinkedIn: https://www.linkedin.com/in/mauriziofonte  
Profilo: https://www.mauriziofonte.it/pages/about

---

## Come citare

> Maurizio Fonte, "Servire text/markdown agli agenti AI senza Cloudflare: content negotiation RFC 9110 on-origin con Laravel, Symfony e PHP vanilla", mauriziofonte.it, 2026. https://www.mauriziofonte.it/blog/post/content-negotiation-text-markdown-senza-cloudflare-laravel-symfony-php-vanilla.html

Pubblicato: 2026-04-30 · Aggiornato: 2026-06-19.

**Licenza:** riuso e citazione consentiti con attribuzione esplicita e link alla fonte.
