Laravel

Three approaches — Markdown-as-source, HTML-in-database, or middleware that converts rendered Blade/Livewire output to Markdown on request.

Laravel content negotiation comes down to where your content lives and how it’s rendered. Pick the approach that matches your app:

  • Option 1 — Markdown is the source. Docs/posts authored as .md files on disk. Serve raw to agents, render to HTML for browsers.
  • Option 2 — HTML is the source, stored in the database. Content authored in a WYSIWYG editor, stored as HTML. Convert to Markdown on request for agents.
  • Option 3 — HTML is rendered from Blade / Livewire. Content lives in your templates + data layer; HTML is produced per request. Middleware intercepts the rendered response and converts to Markdown when an agent asked.

Shared: detect preference

All three options use the same logic to pick a representation. Put it in a trait so each controller and the middleware can reuse it:

<?php
// app/Http/Concerns/NegotiatesMarkdown.php
namespace App\Http\Concerns;

use Illuminate\Http\Request;

trait NegotiatesMarkdown
{
    protected function prefersMarkdown(Request $request): bool
    {
        return $this->pickType($request, ['text/html', 'text/markdown']) === 'text/markdown';
    }

    /**
     * Pick the best representation from the given produces list, respecting
     * q-values, specificity, and q=0 rejections. Returns null when the client
     * accepts nothing we can serve.
     */
    protected function pickType(Request $request, array $produces): ?string
    {
        $accept = $request->header('Accept');
        if (! $accept) return $produces[0] ?? null;

        $entries = [];
        foreach (explode(',', $accept) as $raw) {
            $parts = array_map('trim', explode(';', trim($raw)));
            $type = strtolower(array_shift($parts));
            if ($type === '') continue;
            $q = 1.0;
            foreach ($parts as $param) {
                [$name, $value] = array_pad(array_map('trim', explode('=', $param, 2)), 2, '');
                if ($name === 'q' && is_numeric($value)) {
                    $q = max(0.0, min(1.0, (float) $value));
                }
            }
            $specificity = $type === '*/*' ? 0 : (str_ends_with($type, '/*') ? 1 : 2);
            $entries[] = compact('type', 'q', 'specificity');
        }
        // Preserve client order — position is used for tiebreaks below.

        $best = null; $bestQ = -1.0; $bestPosition = PHP_INT_MAX;
        foreach ($produces as $candidate) {
            // RFC 9110 §12.5.1: pick the most specific matching range, not
            // the first one — so `text/html;q=0, */*;q=1` correctly rejects
            // text/html instead of letting the wildcard override.
            $matched = null; $matchedPosition = PHP_INT_MAX;
            foreach ($entries as $i => $e) {
                $isMatch = $e['type'] === '*/*' ||
                    (str_ends_with($e['type'], '/*') && str_starts_with($candidate, substr($e['type'], 0, -1))) ||
                    $e['type'] === $candidate;
                if (! $isMatch) continue;
                if ($matched === null ||
                    $e['specificity'] > $matched['specificity'] ||
                    ($e['specificity'] === $matched['specificity'] && $i < $matchedPosition)
                ) {
                    $matched = $e;
                    $matchedPosition = $i;
                }
            }
            if ($matched === null) continue;
            if ($matched['q'] <= 0) continue; // explicit rejection

            if ($matched['q'] > $bestQ ||
                ($matched['q'] === $bestQ && $matchedPosition < $bestPosition)
            ) {
                $bestQ = $matched['q'];
                $bestPosition = $matchedPosition;
                $best = $candidate;
            }
        }
        return $best;
    }
}

See Accept parsing & q-values for the full algorithm — it respects q-values, breaks ties by specificity, uses client order on full ties, and honors q=0 explicit rejections. Symfony’s getAcceptableContentTypes() returns types in priority order but an equality check against $types[0] alone fails on mixed wildcard headers like Accept: text/*, text/markdown (both q=1 — your server should serve Markdown as more specific, but $types[0] would be text/* and the equality check misses it).


Option 1: Markdown-as-source

Author content as .md files in resources/content/docs/. Serve raw when agents ask; render with CommonMark for browsers.

composer require league/commonmark
<?php
namespace App\Http\Controllers;

use App\Http\Concerns\NegotiatesMarkdown;
use Illuminate\Http\Request;
use League\CommonMark\CommonMarkConverter;

class DocsController extends Controller
{
    use NegotiatesMarkdown;

    public function show(Request $request, string $slug)
    {
        $slug = basename($slug);
        $path = resource_path("content/docs/{$slug}.md");

        if (! file_exists($path)) {
            abort(404);
        }

        $markdown = file_get_contents($path);

        if ($this->prefersMarkdown($request)) {
            return response($markdown, 200)
                ->header('Content-Type', 'text/markdown; charset=utf-8')
                ->header('Vary', 'Accept');
        }

        $html = (new CommonMarkConverter())->convert($markdown);

        return response()
            ->view('docs', ['content' => $html])
            ->header('Vary', 'Accept');
    }
}

Option 2: HTML-in-database

Content stored as HTML in the database (TipTap, TinyMCE, Trix, Filament editor, etc.). Convert to Markdown on request with league/html-to-markdown, cache the result.

composer require league/html-to-markdown
<?php
namespace App\Http\Controllers;

use App\Http\Concerns\NegotiatesMarkdown;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use League\HTMLToMarkdown\HtmlConverter;

class PostsController extends Controller
{
    use NegotiatesMarkdown;

    public function show(Request $request, string $slug)
    {
        $post = Post::where('slug', $slug)->firstOrFail();

        if ($this->prefersMarkdown($request)) {
            $markdown = Cache::rememberForever(
                "post:{$post->id}:md:v1",
                fn () => (new HtmlConverter([
                    'header_style' => 'atx',
                    'strip_tags' => true,
                ]))->convert($post->body_html),
            );

            return response($markdown, 200)
                ->header('Content-Type', 'text/markdown; charset=utf-8')
                ->header('Vary', 'Accept');
        }

        return response()
            ->view('posts.show', compact('post'))
            ->header('Vary', 'Accept');
    }
}

Invalidate the cache when the post is edited:

// app/Models/Post.php
protected static function booted(): void
{
    static::saved(fn (Post $post) => Cache::forget("post:{$post->id}:md:v1"));
    static::deleted(fn (Post $post) => Cache::forget("post:{$post->id}:md:v1"));
}

Option 3: Rendered Blade / Livewire

Content is assembled from Blade templates, Livewire components, or Inertia/Filament pages — no single HTML source in the DB. Use middleware that lets the response render as normal, then converts the rendered HTML to Markdown when an agent asked.

composer require league/html-to-markdown
<?php
namespace App\Http\Middleware;

use App\Http\Concerns\NegotiatesMarkdown;
use Closure;
use Illuminate\Http\Request;
use League\HTMLToMarkdown\HtmlConverter;

class NegotiateMarkdown
{
    use NegotiatesMarkdown;

    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);
        $this->appendVaryAccept($response);

        if ($this->pickType($request, ['text/html', 'text/markdown']) !== 'text/markdown') {
            return $response;
        }

        $contentType = $response->headers->get('Content-Type') ?? '';
        if (! str_contains($contentType, 'html')) return $response;

        $markdown = $this->extractAndConvert($response->getContent());
        $response->setContent($markdown);
        $response->headers->set('Content-Type', 'text/markdown; charset=utf-8');
        $response->headers->remove('Content-Length');

        return $response;
    }

    protected function appendVaryAccept(\Symfony\Component\HttpFoundation\Response $response): void
    {
        $existing = $response->headers->get('Vary');
        if (! $existing) {
            $response->headers->set('Vary', 'Accept');
            return;
        }

        $tokens = array_map('trim', explode(',', strtolower($existing)));
        if (! in_array('accept', $tokens, true)) {
            $response->headers->set('Vary', $existing . ', Accept');
        }
    }

    protected function extractAndConvert(string $html): string
    {
        // Pull just the article/main region out of the rendered page
        // so nav, footer, and layout chrome don't end up in the output.
        libxml_use_internal_errors(true);
        $dom = new \DOMDocument();
        $dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOERROR);
        libxml_clear_errors();

        $xpath = new \DOMXPath($dom);
        $node = $xpath->query('//main')->item(0)
            ?? $xpath->query('//article')->item(0);

        $fragment = '';
        if ($node) {
            foreach ($node->childNodes as $child) {
                $fragment .= $dom->saveHTML($child);
            }
        } else {
            $fragment = $html;
        }

        return (new HtmlConverter([
            'header_style' => 'atx',
            'strip_tags' => true,
            'remove_nodes' => 'script style nav footer header aside',
        ]))->convert($fragment);
    }
}

Register globally in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(App\Http\Middleware\NegotiateMarkdown::class);
})

Livewire caveat: Livewire’s initial server render is regular Blade HTML, so it flows through this middleware fine. Livewire’s follow-up AJAX update requests return JSON (Content-Type: application/json), so the str_contains('html') check skips them — correct.

Inertia caveat: Inertia navigations send X-Inertia: true and expect a JSON response. The Content-Type check again skips those. The initial full-page Inertia render is HTML, so agents asking for Markdown on any Inertia page get the rendered content converted.

Filament caveat: Filament admin pages aren’t content you want agents reading. Scope the middleware to a route group (e.g., /docs/*, /blog/*) instead of registering globally if your site has an admin area.

Caching Option 3’s output

Converting the full rendered HTML on every request is wasteful. Wrap the conversion in Cache::remember keyed on the request URL:

$cacheKey = 'md:' . sha1($request->fullUrl());
$markdown = Cache::remember($cacheKey, now()->addMinutes(10),
    fn () => $this->extractAndConvert($response->getContent()),
);

Invalidate via model observers, or rely on short TTLs if your content changes frequently.


Common to all three: correctness

  • Vary: Accept is added to both branches so downstream caches key on Accept correctly. See the Vary guide.
  • For strict 406 behavior, extend the Accept check to return a 406 response when the client rejects every representation you produce. See the 406 guide.

Verify

curl -sI -H "Accept: text/markdown" http://localhost:8000/docs/installation
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept

curl -sI http://localhost:8000/docs/installation
# Content-Type: text/html; charset=UTF-8
# Vary: Accept