Saltar al contenido

Laravel 13.35, publicada el 6 de octubre de 2026, añade response()->markdown(), un método para devolver una respuesta en Markdown con la cabecera Content-Type que indica ese formato. Junto con $request->wantsMarkdown(), que existe desde marzo de 2026, nos permite responder en la misma URL con Markdown a un agente de IA que lo pide y con el HTML de siempre al navegador.

En esta entrada vamos a servir los posts de un blog en Markdown a los agentes que lo piden, con una URL .md para los que no lo piden, un 404 que también puedan leer y sus tests. Después veremos qué hace el paquete de Spatie con las páginas que solo existen en HTML y los problemas que nos hemos encontrado al probarlo. Todo está probado, en el momento de crear esta entrada, con Laravel 13.35, PHP 8.5, Pest 5 y Claude Code 2.1.

Qué pide un agente de IA

Cada petición HTTP lleva una cabecera Accept con los formatos que el cliente sabe leer, por orden de preferencia, y el servidor elige con cuál responder. Es lo que se llama negociación de contenido, lo mismo que hace una API cuando devuelve JSON a quien lo pide.

Para ver qué envía un agente de verdad, le hemos pedido a Claude Code que abra con su herramienta WebFetch la dirección https://httpbin.org/headers, un servicio que devuelve las cabeceras de la petición que recibe. Estas son las 2 que nos interesan:

Accept: text/markdown, text/html, */*
User-Agent: Claude-User (claude-code/2.1.283; +https://support.anthropic.com/)

Claude Code pide primero Markdown, después HTML y, si no hay ninguno de los 2, cualquier otro formato. Si tu aplicación entiende esa cabecera, le puede devolver solo el contenido de la página, sin el menú, los estilos ni los scripts que el agente no necesita.

Requisitos

wantsMarkdown() y acceptsMarkdown() llegaron a la clase Request el 17 de marzo de 2026, en Laravel 12.55 y en Laravel 13.0 (PR #59238). response()->markdown() es de Laravel 13.35 (PR #61800, notas de la versión). Si tu proyecto está en una versión anterior, puedes devolver la misma respuesta con la cabecera a mano:

return response($markdown, 200, ['Content-Type' => 'text/markdown']);

Las 2 formas devuelven Content-Type: text/markdown; charset=utf-8. El charset indica la codificación de los caracteres. Lo añade Symfony, el componente sobre el que Laravel construye sus respuestas, a todos los tipos que empiezan por text/.

Partimos de un proyecto de Laravel 13 con su base de datos configurada. Los comandos de esta entrada son los de php artisan; si usas Sail, ejecútalos con ./vendor/bin/sail artisan.

Los posts del blog

Vamos a guardar los posts en Markdown, así ya tenemos el contenido que queremos entregar al agente. Crea el modelo con su migración:

php artisan make:model Post -m

Abre la migración que se acaba de crear y deja el método up() de la siguiente forma:

    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->string('excerpt');
            $table->longText('body');
            $table->timestamps();
        });
    }

En el modelo, app/Models/Post.php, vamos a añadir un método que construye el Markdown completo del post, con el título como encabezado y el resumen debajo:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['title', 'slug', 'excerpt', 'body'])]
class Post extends Model
{
    public function toMarkdown(): string
    {
        return "# {$this->title}\n\n{$this->excerpt}\n\n{$this->body}\n";
    }
}

Para tener un post de ejemplo, crea un seeder:

php artisan make:seeder PostSeeder

Y en database/seeders/PostSeeder.php vamos a añadir un post con encabezados, bloques de código y una lista:

<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class PostSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Post::create([
            'title' => 'Colas en Laravel con la base de datos',
            'slug' => 'colas-laravel-base-de-datos',
            'excerpt' => 'Cómo enviar un trabajo a la cola y procesarlo con un worker, sin Redis.',
            'body' => <<<'MARKDOWN'
                ## Crear el job

                Vamos a crear un job que envía el correo de bienvenida:

                ```bash
                php artisan make:job SendWelcomeEmail
                ```

                ## Procesar la cola

                Con la conexión `database`, los trabajos se guardan en la tabla `jobs`. Para procesarlos:

                ```bash
                php artisan queue:work
                ```

                - El worker se queda escuchando hasta que lo detienes.
                - Si cambias el código del job, reinícialo con `php artisan queue:restart`.
                MARKDOWN,
        ]);
    }
}

Ejecuta la migración y el seeder:

php artisan migrate
php artisan db:seed --class=PostSeeder

Responder en Markdown o en HTML

Vamos a crear el controlador:

php artisan make:controller PostController

Abre app/Http/Controllers/PostController.php y cambia su código por el siguiente:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class PostController extends Controller
{
    public function show(Request $request, Post $post): Response
    {
        if ($request->wantsMarkdown()) {
            return response()->markdown($post->toMarkdown());
        }

        return response()->view('posts.show', ['post' => $post]);
    }
}

Si te fijas, todo depende de una condición. wantsMarkdown() devuelve true cuando el formato preferido de la cabecera Accept es text/markdown, y en ese caso respondemos con response()->markdown(). Para el resto, la vista de siempre, con response()->view() en lugar de view() porque más abajo le vamos a añadir una cabecera.

Crea la vista resources/views/posts/show.blade.php, que convierte el Markdown del post a HTML con Str::markdown():

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $post->title }}</title>
    <meta name="description" content="{{ $post->excerpt }}">
</head>
<body>
    <header>
        <nav>
            <a href="/">Inicio</a>
            <a href="/blog">Blog</a>
            <a href="/cursos">Cursos</a>
        </nav>
    </header>

    <main>
        <article>
            <h1>{{ $post->title }}</h1>
            <p>{{ $post->excerpt }}</p>
            {!! Str::markdown($post->body) !!}
        </article>
    </main>

    <footer>
        <p>&copy; {{ date('Y') }} Mi blog de Laravel</p>
    </footer>
</body>
</html>

Y en routes/web.php vamos a añadir la ruta, que busca el post por su slug:

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/blog/{post:slug}', [PostController::class, 'show'])->name('posts.show');

Probarlo como un agente

El movimiento se demuestra andando. Vamos a pedir el post con las mismas cabeceras que envía Claude Code:

curl -i -H "Accept: text/markdown, text/html, */*" \
  -H "User-Agent: Claude-User (claude-code/2.1.283; +https://support.anthropic.com/)" \
  http://localhost/blog/colas-laravel-base-de-datos

Esta es la respuesta, recortada a las cabeceras que nos interesan:

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8

# Colas en Laravel con la base de datos

Cómo enviar un trabajo a la cola y procesarlo con un worker, sin Redis.

## Crear el job

Vamos a crear un job que envía el correo de bienvenida:

```bash
php artisan make:job SendWelcomeEmail
```

## Procesar la cola

Con la conexión `database`, los trabajos se guardan en la tabla `jobs`. Para procesarlos:

```bash
php artisan queue:work
```

- El worker se queda escuchando hasta que lo detienes.
- Si cambias el código del job, reinícialo con `php artisan queue:restart`.

Es el Markdown del post, tal como está guardado, con Content-Type: text/markdown. Con la cabecera Accept de un navegador, o con curl sin cabeceras, que envía Accept: */*, la misma URL devuelve el HTML. Estos son los tamaños de cada respuesta:

Agente:    200 text/markdown; charset=utf-8 523 bytes
Navegador: 200 text/html; charset=utf-8 1365 bytes
curl:      200 text/html; charset=utf-8 1365 bytes

En esta página de ejemplo, sin estilos ni scripts, el Markdown ocupa 523 bytes frente a los 1.365 del HTML. Con una página real la diferencia es mucho mayor, como veremos más abajo con la página de bienvenida de Laravel.

wantsMarkdown o acceptsMarkdown

La clase Request tiene 2 métodos para el Markdown con nombres muy parecidos, pero no hacen lo mismo. Para verlo, vamos a probar los 2 con 7 cabeceras distintas en Tinker (php artisan tinker):

use Illuminate\Http\Request;

$headers = [
    'text/markdown',
    'text/markdown, text/html, */*',
    'text/html, text/markdown',
    'text/html;q=0.9, text/markdown',
    'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    '*/*',
    'application/json',
];

foreach ($headers as $accept) {
    $request = Request::create('/', server: ['HTTP_ACCEPT' => $accept]);

    printf(
        "%-66s wantsMarkdown: %-5s acceptsMarkdown: %s\n",
        $accept,
        var_export($request->wantsMarkdown(), true),
        var_export($request->acceptsMarkdown(), true),
    );
}
text/markdown                                                      wantsMarkdown: true  acceptsMarkdown: true
text/markdown, text/html, */*                                      wantsMarkdown: true  acceptsMarkdown: true
text/html, text/markdown                                           wantsMarkdown: false acceptsMarkdown: true
text/html;q=0.9, text/markdown                                     wantsMarkdown: true  acceptsMarkdown: true
text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8    wantsMarkdown: false acceptsMarkdown: true
*/*                                                                wantsMarkdown: false acceptsMarkdown: true
application/json                                                   wantsMarkdown: false acceptsMarkdown: false

acceptsMarkdown() devuelve true si el cliente acepta Markdown entre otros formatos, y */* significa que acepta cualquiera. Por eso da true con la cabecera de un navegador y con la de curl. Si hubiéramos usado acceptsMarkdown() en el controlador, el navegador habría recibido el Markdown en lugar de la página.

wantsMarkdown() solo mira el formato preferido: el primero, después de ordenar la cabecera por su peso q, un valor entre 0 y 1 con el que el cliente dice cuánto prefiere cada formato. Si un formato no lleva peso, vale 1. Por eso text/html;q=0.9, text/markdown da true: el Markdown vale 1 y queda por delante del HTML.

La cabecera Vary

Ahora la misma URL tiene 2 respuestas distintas según la cabecera Accept. Si entre tu aplicación y el cliente hay una caché, como la del navegador, la de un proxy o la de una CDN, tiene que saberlo. Si no, podría guardar la versión en Markdown y entregársela a un navegador, o al revés. Para eso está la cabecera Vary (RFC 9110): con Vary: Accept, la caché solo reutiliza una respuesta guardada para las peticiones que traen la misma cabecera Accept.

Abre app/Http/Controllers/PostController.php y añade la cabecera a las 2 respuestas. De paso, vamos a añadir el método markdown(), que usaremos en el siguiente apartado:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class PostController extends Controller
{
    public function show(Request $request, Post $post): Response
    {
        if ($request->wantsMarkdown()) {
            return response()->markdown($post->toMarkdown(), headers: ['Vary' => 'Accept']);
        }

        return response()
            ->view('posts.show', ['post' => $post])
            ->header('Vary', 'Accept');
    }

    public function markdown(Post $post): Response
    {
        return response()->markdown($post->toMarkdown());
    }
}

El tercer parámetro de response()->markdown() son las cabeceras extra, igual que en response()->make(). La cabecera Content-Type no se puede cambiar por ahí: hemos probado a pasar text/plain y la respuesta sigue saliendo como text/markdown. Ahora las 2 respuestas llevan la cabecera Vary:

HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/markdown; charset=utf-8

HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/html; charset=utf-8

Una URL .md para quien no envía la cabecera

No todos los clientes envían Accept: text/markdown. Para que también puedan leer el post en Markdown, vamos a ofrecerlo en la misma URL terminada en .md, como hace también el paquete de Spatie que veremos más abajo. En routes/web.php, añade la ruta que usa el método markdown() del controlador:

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/blog/{post:slug}.md', [PostController::class, 'markdown'])->name('posts.markdown');
Route::get('/blog/{post:slug}', [PostController::class, 'show'])->name('posts.show');

Fíjate en el orden. Si la ruta .md va después de la otra, la URL /blog/colas-laravel-base-de-datos.md devuelve un 404. El parámetro {post:slug} admite puntos, así que la primera ruta se queda con la URL entera, busca un post con el slug colas-laravel-base-de-datos.md y no lo encuentra. Con la ruta .md delante, cualquier cliente recibe el Markdown, aunque no lo pida en la cabecera Accept:

curl -i http://localhost/blog/colas-laravel-base-de-datos.md
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8

# Colas en Laravel con la base de datos
...

Para que quien lea el HTML sepa que existe esa versión, añade un enlace en el <head> de resources/views/posts/show.blade.php, debajo de la descripción. Su rel="alternate" indica que es la misma página en otro formato, y type dice cuál:

<link rel="alternate" type="text/markdown" href="{{ route('posts.markdown', $post) }}">

El navegador no lo muestra, pero queda en el HTML de la página:

<link rel="alternate" type="text/markdown" href="http://localhost/blog/colas-laravel-base-de-datos.md">

El 404 también en Markdown

Si un agente pide un post que no existe, Laravel le responde con su página de error en HTML: 6.586 bytes, con sus estilos, para decir "Not Found". Vamos a responderle en Markdown. Abre bootstrap/app.php y añade en withExceptions() una función para la excepción NotFoundHttpException:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );

        $exceptions->render(function (NotFoundHttpException $e, Request $request) {
            if ($request->wantsMarkdown()) {
                return response()->markdown("# Página no encontrada\n\nNo existe ninguna página en {$request->url()}.\n", 404);
            }
        });
    })->create();

Laravel ejecuta esa función cuando una petición termina en un 404, también cuando el post de la ruta no existe. Si la petición quiere Markdown, devolvemos nuestra respuesta con el código 404. Si no, la función no devuelve nada y Laravel muestra su página de error como siempre. Esto es lo que recibe ahora un agente:

HTTP/1.1 404 Not Found
Content-Type: text/markdown; charset=utf-8

# Página no encontrada

No existe ninguna página en http://localhost/blog/no-existe.

Y el navegador sigue recibiendo la página de error en HTML.

Tests con Pest

Vamos a cubrir los 4 casos con tests de Pest. Lo que trae la versión 5 de Pest lo tienes en Pest 5: el motor Tia, el plugin de agentes y los evals. Con Pest instalado, crea el test:

php artisan make:test PostMarkdownTest --pest

Abre tests/Feature/PostMarkdownTest.php y sustituye su contenido por el siguiente:

<?php

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function () {
    Post::create([
        'title' => 'Colas en Laravel',
        'slug' => 'colas-laravel',
        'excerpt' => 'Cómo procesar trabajos en segundo plano.',
        'body' => "## Crear el job\n\nVamos a crear un job.",
    ]);
});

test('un agente que pide Markdown recibe el post en Markdown', function () {
    test()->get('/blog/colas-laravel', ['Accept' => 'text/markdown, text/html, */*'])
        ->assertOk()
        ->assertHeader('Content-Type', 'text/markdown; charset=utf-8')
        ->assertHeader('Vary', 'Accept')
        ->assertContent("# Colas en Laravel\n\nCómo procesar trabajos en segundo plano.\n\n## Crear el job\n\nVamos a crear un job.\n");
});

test('un navegador recibe el HTML con el enlace a la versión en Markdown', function () {
    test()->get('/blog/colas-laravel', ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'])
        ->assertOk()
        ->assertHeader('Content-Type', 'text/html; charset=utf-8')
        ->assertHeader('Vary', 'Accept')
        ->assertSee('<link rel="alternate" type="text/markdown" href="'.route('posts.markdown', 'colas-laravel').'">', escape: false);
});

test('la URL .md devuelve Markdown sin la cabecera Accept', function () {
    test()->get('/blog/colas-laravel.md')
        ->assertOk()
        ->assertHeader('Content-Type', 'text/markdown; charset=utf-8');
});

test('un agente recibe el 404 en Markdown', function () {
    test()->get('/blog/no-existe', ['Accept' => 'text/markdown'])
        ->assertNotFound()
        ->assertHeader('Content-Type', 'text/markdown; charset=utf-8')
        ->assertSee('# Página no encontrada');
});

Si te fijas, cada test envía la cabecera Accept que corresponde: la de Claude Code, la de un navegador o ninguna. El del navegador comprueba también el enlace a la versión .md, construido con route() para que no dependa de tu APP_URL. Vamos a ejecutarlos:

php artisan test tests/Feature/PostMarkdownTest.php
   PASS  Tests\Feature\PostMarkdownTest
  ✓ un agente que pide Markdown recibe el post en Markdown                                             0.55s  
  ✓ un navegador recibe el HTML con el enlace a la versión en Markdown                                 0.03s  
  ✓ la URL .md devuelve Markdown sin la cabecera Accept                                                0.01s  
  ✓ un agente recibe el 404 en Markdown                                                                0.01s  

  Tests:    4 passed (19 assertions)
  Duration: 0.66s

Páginas que solo tienes en HTML: el paquete de Spatie

Todo lo anterior funciona porque el contenido ya está en Markdown. Si tus páginas se generan con Blade o con Livewire, el paquete spatie/laravel-markdown-response convierte el HTML de la respuesta a Markdown con la librería league/html-to-markdown. Vamos a instalarlo en el mismo proyecto:

composer require spatie/laravel-markdown-response:^1.2
  - Locking league/html-to-markdown (5.1.4)
  - Locking spatie/laravel-markdown-response (1.2.2)
  - Locking spatie/laravel-package-tools (1.93.3)
  - Locking spatie/php-attribute-reader (1.1.0)
No security vulnerability advisories found.

La URL .md deja de funcionar

Nada más instalarlo, sin cambiar ninguna configuración, el test de la URL .md falla:


   FAIL  Tests\Feature\PostMarkdownTest
  ✓ un agente que pide Markdown recibe el post en Markdown                                             0.75s
  ✓ un navegador recibe el HTML con el enlace a la versión en Markdown                                 0.03s
  ⨯ la URL .md devuelve Markdown sin la cabecera Accept                                                0.02s
  ✓ un agente recibe el 404 en Markdown                                                                0.01s
  ──────────────────────────────────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\PostMarkdownTest > la URL .md devuelve Markdown sin la cabecera Accept
  Header [Content-Type] was found, but value [text/html; charset=utf-8] does not match [text/markdown; charset=utf-8].
Failed asserting that two strings are equal.
  -'text/markdown; charset=utf-8'
  +'text/html; charset=utf-8'

  at tests/Feature/PostMarkdownTest.php:36
     32▕
     33▕ test('la URL .md devuelve Markdown sin la cabecera Accept', function () {
     34▕     test()->get('/blog/colas-laravel.md')
     35▕         ->assertOk()
  ➜  36▕         ->assertHeader('Content-Type', 'text/markdown; charset=utf-8');
     37▕ });
     38▕
     39▕ test('un agente recibe el 404 en Markdown', function () {
     40▕     test()->get('/blog/no-existe', ['Accept' => 'text/markdown'])

  Tests:    1 failed, 3 passed (19 assertions)
  Duration: 0.86s

El paquete registra un middleware global, que se ejecuta en todas las peticiones, y ese middleware quita el .md final de la URL antes de buscar la ruta. La /blog/colas-laravel.md del test llega a la ruta del post sin el .md, con Accept: */*, y nuestro controlador devuelve el HTML. Para que respete nuestra ruta, publica su configuración:

php artisan vendor:publish --tag=markdown-response-config

Y en config/markdown-response.php, dentro de detection, desactiva esa opción:

'detect_via_md_suffix' => false,

Con ese cambio, los 4 tests vuelven a pasar.

Convertir una página a Markdown

El paquete solo convierte las rutas que llevan su middleware, ProvideMarkdownResponse. Vamos a probarlo con la página de bienvenida de Laravel, que solo existe en HTML. En routes/web.php, añade el middleware a la ruta /:

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
use Spatie\MarkdownResponse\Middleware\ProvideMarkdownResponse;

Route::get('/', function () {
    return view('welcome');
})->middleware(ProvideMarkdownResponse::class);

Route::get('/blog/{post:slug}.md', [PostController::class, 'markdown'])->name('posts.markdown');
Route::get('/blog/{post:slug}', [PostController::class, 'show'])->name('posts.show');

Y vamos a pedirla en Markdown:

curl -i -H "Accept: text/markdown" http://localhost/
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=UTF-8
Vary: Accept
X-Robots-Tag: noindex
X-Markdown-Tokens: 99
Content-Signal: ai-train=disallow, ai-input=allow, search=allow

Laravel     Let's get started
=================

With so many options available to you,
 we suggest you start with the following:

- Read the [ Documentation    ](https://laravel.com/docs)
- Watch video tutorials at [ Laracasts    ](https://laracasts.com)

- [ Deploy now ](https://cloud.laravel.com)

 v13.35.0 [ View changelog    ](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)

Los 70.403 bytes del HTML, que lleva los estilos y los SVG en la propia página, se quedan en 395 bytes de Markdown. La conversión es automática y se nota: el título de la pestaña, "Laravel", queda pegado al encabezado y los enlaces llevan espacios de más. Para una página como esta es suficiente, pero si quieres un Markdown limpio, lo mejor es tener el contenido en Markdown, como los posts.

Además, la respuesta lleva 4 cabeceras que pone el paquete:

  • Vary: Accept, la misma que pusimos nosotros. Ojo, porque solo la añade a la respuesta en Markdown: la del HTML sale sin ella, así que si una caché guarda estas páginas, tendrás que añadirla tú.
  • X-Robots-Tag: noindex, para que los buscadores no indexen la versión en Markdown.
  • X-Markdown-Tokens, una estimación de los tokens del Markdown: el número de caracteres dividido entre 4.
  • Content-Signal, que dice qué se puede hacer con el contenido: no usarlo para entrenar modelos (ai-train=disallow), sí para responder a una pregunta (ai-input=allow) y sí en buscadores (search=allow). Se cambia en content_signals, en la configuración del paquete.

También decide de otra forma cuándo responder en Markdown. Lo hace si text/markdown aparece en cualquier parte de la cabecera Accept, aunque el cliente prefiera HTML, así que con la misma cabecera el paquete y nuestro controlador responden distinto:

Spatie, Accept: text/html, text/markdown;q=0.1 -> text/markdown; charset=UTF-8
wantsMarkdown(), la misma cabecera -> text/html; charset=utf-8

Y responde en Markdown, aunque no lo pidan, a los clientes cuya cabecera User-Agent, con la que se identifican, contiene alguno de los nombres de la opción detect_via_user_agents, como ClaudeBot o PerplexityBot. La lista incluye también Anthropic, que aparece en el User-Agent de Claude Code por la dirección de soporte:

User-Agent: ClaudeBot, sin Accept -> text/markdown; charset=UTF-8
User-Agent: Claude-User, sin Accept -> text/markdown; charset=UTF-8

La caché de las conversiones

Para no convertir la misma página en cada petición, el paquete guarda el Markdown en la caché de Laravel durante una hora. Al probarlo nos hemos encontrado 3 problemas: las conversiones tardan en actualizarse, se comparten entre usuarios y el comando que las borra vacía también la caché de tu aplicación. La primera, que si cambias la página, los agentes reciben la versión anterior hasta que caduca. Hemos cambiado el título de la página de bienvenida por "Empecemos". El HTML lo muestra al momento, pero el Markdown no lo tiene hasta vaciar la caché del paquete:

HTML: Empecemos
Markdown: Laravel     Let's get started
Markdown response cache cleared.
Markdown: Laravel     Empecemos

La segunda, que la clave de la caché sale de la URL, no del usuario. Lo hemos comprobado con un test: en una página protegida con auth que saluda al usuario, Ana la pide en Markdown y después Luis recibe el saludo de Ana:

array:2 [
  "Ana" => """
    Hola, Ana\n
    =========\n
    """
  "Luis" => """
    Hola, Ana\n
    =========\n
    """
] // tests/Feature/SpatieCacheTest.php:24

Así que usa el middleware solo en páginas públicas, que muestran lo mismo a todo el mundo.

La tercera está en markdown-response:clear. Ese comando vacía entero el store de caché del paquete, el almacén donde guarda las conversiones. Por defecto es el mismo que usa tu aplicación. Para comprobarlo, vamos a guardar un carrito en la caché y a ejecutar el comando desde Tinker:

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;

Cache::put('carrito:42', ['producto' => 7], 3600);
Artisan::call('markdown-response:clear');
echo 'Store de Markdown: '.(config('markdown-response.cache.store') ?? 'el de por defecto').PHP_EOL;
echo 'carrito:42 después de markdown-response:clear: '.json_encode(Cache::get('carrito:42')).PHP_EOL;
Store de Markdown: el de por defecto
carrito:42 después de markdown-response:clear: null

El carrito ha desaparecido. La solución es darle al paquete un store propio. En config/cache.php, añade uno en stores:

'markdown' => [
    'driver' => 'file',
    'path' => storage_path('framework/cache/markdown'),
],

Y en tu .env, indica al paquete que lo use:

MARKDOWN_RESPONSE_CACHE_STORE=markdown

Ahora markdown-response:clear solo vacía la carpeta storage/framework/cache/markdown y el carrito sigue en su sitio:

Store de Markdown: markdown
carrito:42 después de markdown-response:clear: {"producto":7}

Cuál usar

Si tu contenido ya está en Markdown, como los posts de un blog o una documentación, con wantsMarkdown() y response()->markdown() decides exactamente qué recibe el agente, sin conversiones ni caché, en unas pocas líneas. El paquete de Spatie tiene sentido para las páginas que solo existen en HTML, siempre que sean públicas y le des un store de caché propio.

Con esto, un agente puede leer tu web. Si además quieres que pueda usarla, con acciones que defines tú, tienes WebMCP en el navegador y los servidores MCP en Laravel, que puedes ver en Crea servidores MCP en Laravel. Espero que te sirva.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje