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
.mdfiles 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: Acceptis added to both branches so downstream caches key on Accept correctly. See the Vary guide.- For strict
406behavior, 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