spatie/laravel-markdown-blade-component
Spatie's Laravel Markdown Blade Component lets you render Markdown directly in Blade views using a simple component. Built on CommonMark, it supports GitHub-flavored Markdown, caching, and safe HTML output, making it ideal for blogs, docs, and user-generated content.
Install the package and (optionally) publish its config:
composer require spatie/laravel-markdown-blade-component
php artisan vendor:publish --tag="markdown-config"
The service provider is auto-discovered; there is nothing else to register. The first place to look is config/markdown.php — that file is the source of truth for which CommonMark extensions are enabled by default (core CommonMark, tables, strikethrough, autolinks, task lists, etc.) and for the renderer/CommonMark options the package ships with. Skim it before you write any markup, because half of the "why doesn't this render" questions are answered there.
The fastest first use case is prose in a Blade view — changelogs, help pages, CMS bodies, README-style docs:
<x-markdown>
# Release notes
- Added **faster** rendering
- Fixed the `null` bug in `Post::render()`
| Version | Date |
|---------|------------|
| 2.0 | 2026-02-22 |
</x-markdown>
The component renders the slot through League CommonMark and returns an HtmlString, so you do not wrap it in {!! !!}.
When you need rendered HTML outside a view (mail, API responses, a model accessor), resolve the renderer directly:
use Spatie\LaravelMarkdown\MarkdownRenderer;
$html = app(MarkdownRenderer::class)->toHtml($post->body);
That is enough to ship. Everything else in this assessment is about choosing the right trust boundary, caching the result, and not tripping over the converter's defaults.
Two trust profiles, chosen per call. Treat internal content and user content as separate pipelines. Author-controlled docs can allow raw HTML; user input must not.
{{-- trusted, authored in-repo --}}
<x-markdown :options="['html_input' => 'allow']">
<div class="callout">Internal note</div>
</x-markdown>
{{-- untrusted, user-submitted --}}
<x-markdown :options="['html_input' => 'escape', 'allow_unsafe_links' => false]">
{{ $comment->body }}
</x-markdown>
Set html_input explicitly on every untrusted path rather than relying on the package default — it is the one option you do not want to inherit silently from a config file a teammate edited.
Global defaults in config, per-render overrides in the component. Put extensions the whole app needs (tables, footnotes, front matter) in config/markdown.php; pass :extensions only for one-off cases like a docs route that needs heading permalinks. Same for :options — keep the config as the baseline and override the renderer options that differ for a single view.
Pre-render and store the HTML on write. Rendering on every page view is wasteful for content that changes rarely. Do it in a model observer and keep the column:
use Spatie\LaravelMarkdown\MarkdownRenderer;
static::saving(function (Post $post) {
$post->body_html = app(MarkdownRenderer::class)
->highlightCode()
->toHtml($post->body ?? '');
});
Then the Blade view is <x-markdown>{!! $post->body_html !!}</x-markdown> — or skip the component entirely and echo the stored string, since the work is already done.
Cache at the component for read-heavy pages.
<x-markdown :cache="true">…</x-markdown>
<x-markdown :cache="now()->addHour()">…</x-markdown>
Use this for CMS pages that render per request but change infrequently; combine it with a cache_key_prefix per environment so staging and production do not share entries.
Syntax highlighting for technical docs. Add :highlight-code="true" (or flip it on in config) when you want fenced blocks highlighted. This is a Shiki-backed path, so treat it as an infrastructure decision, not a view flag — see the gotchas below.
Wrap it once, not everywhere. If more than a couple of views use it, create a thin resources/views/components/prose.blade.php that forwards to <x-markdown> with your house options. It gives you one place to change the trust profile, add class handling, or swap the package later without touching every view.
Programmatic rendering for mail and exports. Mailables and PDF/export jobs should resolve MarkdownRenderer from the container (it is a singleton) rather than instantiating converters themselves. Chain ->highlightCode(), ->cache(...), and ->toHtml() in one fluent call so the options stay consistent with the views.
Test the rendered HTML, not the markdown. Assert on a substring of the output (assertSee('<h1>', false)) or snapshot the HtmlString. That catches extension regressions and accidental escaping far better than testing the input.
<pre>" issue.html_input is a security boundary, not a formatting toggle. allow passes raw HTML through verbatim. For any user-supplied markdown, use escape (or sanitize downstream with HTMLPurifier) and set allow_unsafe_links => false to block javascript: URIs.cache_key_prefix when you change rendering behavior, or flush the store in your deploy script.cache_store in config if your app uses a non-default store; otherwise a queue worker and a web worker can end up on different stores and you get either misses or, worse, stale HTML. Ensure the chosen store is reachable from queue containers.config:cache and extension definitions. If your published config lists extensions as class strings, caching is fine; if it uses new, config:cache can break. Check the shipped shape and prefer the form that survives php artisan config:cache.<x-markdown> produce empty <p> tags. trim() the content before rendering, or normalize it in your wrapper component.<br>. Set the soft_break renderer option if your content is written like a GitHub comment, not a paragraph-per-line document.app(MarkdownRenderer::class) is cheap, but instantiating a new converter per item is not. Resolve once outside a @foreach over comments or feed items.app(MarkdownRenderer::class)->toHtml($markdown) in php artisan tinker — it separates Blade/component/slot problems from CommonMark configuration problems in one step.How can I help you explore Laravel packages today?