sinnbeck/markdom
Markdom is a Laravel/PHP package that parses Markdown into a manipulable DOM, letting you add classes, attributes, and custom transformations before rendering HTML. It offers a clean, extensible API for flexible Markdown output.
Install and publish config.
composer require sinnbeck/markdom
php artisan vendor:publish --tag=markdom-config
No service provider registration is needed on Laravel 9+ (auto-discovery). Look first at config/markdom.php — this is the entire surface area of the package, and almost every question you'll have ("why isn't my table rendering?", "how do I turn off heading permalinks?") is answered by a toggle in that file.
First render. The facade is the fastest path:
use Sinnbeck\Markdom\Facades\Markdom;
$html = Markdom::fromString('# Hello')->toHtml();
The two entry points you'll use 95% of the time are fromString(string $markdown) and fromFile(string $path), both returning a fluent builder you terminate with ->toHtml().
Blade usage. For day-to-day templates, prefer the Blade component/directive over injecting HTML strings:
<x-markdown :content="$post->body" />
If you only need a directive, the package registers one that wraps the facade call. Reach for the Blade form as your default use case — it's what most users of this package install it for.
First real use case. A CMS field stored as markdown in the database:
<article class="prose">
<x-markdown :content="$page->markdown" />
</article>
That's the 80% case. Everything below is about the remaining 20%.
Treat the facade as a builder, not a static helper. Chain everything before calling toHtml() — after toHtml() you have a string and lose access to builder methods. This means:
$html = Markdom::fromString($md)
->withExtensions([...]) // if you need per-call extension tweaks
->toHtml();
Do not Markdom::fromString(...)->toHtml() inside a loop over 500 records; instantiate the conversion once per distinct document and cache the output (see gotchas).
Centralise rendering behind your own service. The facade is convenient but not testable-friendly in isolation and makes swapping engines hard later. Wrap it:
final class MarkdownRenderer
{
public function render(string $markdown): HtmlString
{
return new HtmlString(Markdom::fromString($markdown)->toHtml());
}
}
Then inject this into controllers, Blade components, or API resources. This also gives you one place to add caching, sanitisation, or logging.
Use the Blade component for user-authored content. Wrap it in your own component so you control the wrapper element and prose classes:
{{-- resources/views/components/content.blade.php --}}
<div {{ $attributes->merge(['class' => 'prose max-w-none']) }}>
<x-markdown :content="$slot ?? $content" />
</div>
This is the pattern that scales: presentation lives in Blade, parsing lives in Markdom, and you never scatter <x-markdown> calls with duplicated classes.
Leverage GitHub-style alerts for documentation and internal notes. The package supports alert blockquote syntax:
> [!WARNING]
> Your database will be dropped.
This is genuinely useful for internal wikis and admin UIs. Keep alert syntax out of user-generated content unless you can guarantee the underlying input is safe — otherwise users can inject "fake" warning banners with your styling.
Custom elements are the intended extension point. When you need to render something the markdown spec doesn't cover (callouts with icons, embedded video, custom tabs), define a custom element renderer in the config and map it to a small renderer class. Prefer this over post-processing HTML with regex — regex on HTML is technical debt you will pay for.
Pipeline the output through a sanitiser if the input is untrusted. Markdom's job is conversion, not sanitisation strategy. The right pattern is:
$html = Markdom::fromString($userMarkdown)->toHtml();
$safe = strip_tags($html, '<p><br><strong><em><a><ul><ol><li><h1><h2><h3><blockquote><code><pre>');
Or route through a dedicated sanitiser (e.g. mews/purifier). Do this at the service layer, once, not in a Blade template.
API resources. When returning markdown-backed content via JSON, keep the raw markdown AND the rendered HTML in your payload:
return [
'body_markdown' => $page->body,
'body_html' => app(MarkdownRenderer::class)->render($page->body),
];
Frontends that render markdown client-side need the raw; server-rendered pages need the HTML. Having both avoids a second round-trip later.
It's a thin wrapper — debug at the CommonMark level. The package is a Laravel-flavoured façade over league/commonmark. When something doesn't parse the way you expect (tables, footnotes, definition lists), the fix is almost always an extension toggle in config/markdom.php, not a Markdom bug. Check CommonMark's extension docs when confused; the config keys map fairly directly.
Config caching bites on first deploy. After php artisan config:cache, changes to config/markdom.php won't take effect until you re-run config:cache. If a config change "did nothing," this is nearly always why.
Safe mode is not enabled by default. By default, markdown can contain raw HTML and it will be passed through. If you accept user submissions or comments, either enable the package's safe-mode/HTML-filtering option in config or sanitise the output yourself — do not assume markdown input is safe by virtue of being markdown.
Caching is your next optimisation, not your first. Markdom::fromString() on a single document is fast enough for most pages. Only when you're rendering into a feed, sitemap, or list view does parsing overhead matter. At that point, cache at the document level:
return Cache::rememberForever("md:{$page->id}:{$page->updated_at}", fn () =>
Markdom::fromString($page->body)->toHtml()
);
Key on updated_at (or a content hash) so edits invalidate the cache. Do not cache inside your Blade component — you'll key on the wrong thing.
Blade is compiled — the component caches the compiled view, not the markdown. A common misconception: developers see stale rendered HTML and assume it's the Markdom component caching. It's almost always view:cache or the browser. Clear views before debugging the package.
File loading obeys the filesystem, not your storage disk. fromFile() takes a filesystem path. If your markdown lives on S3 or a custom disk, Storage::disk('docs')->get($path) and pass the string to fromString() instead. Don't assume fromFile('s3://...') works.
Heading permalinks change your anchors. If you enable heading permalinks (a CommonMark extension exposed via config), every heading gets an <a> anchor appended. This can break CSS selectors like h2 > span or :first-child styling. Style the anchor explicitly rather than fighting :last-child.
Raw HTML passthrough interacts with your frontend. Markdown that contains <script> or <iframe> will render as-is by default. If your CSP forbids inline scripts, user markdown with a <script> tag won't execute — but it will still appear in the DOM and confuse anyone looking at the inspector. Strip it at the service layer.
Custom elements are strict about syntax. The custom element parser is whitespace- and case-sensitive in a way that markdown normally isn't. If your custom block isn't rendering, drop to the CommonMark AST view (or the package's debug output if available) rather than guessing — one missing blank line often means it parsed as a paragraph.
Test with a real document, not just the README example. The package's own tests cover the happy path. The bugs you'll hit are at the boundaries: very long documents (parser performance), mixed HTML + markdown, embedded code fences containing markdown syntax, and front matter parsing. Add a fixture file to your test suite that mirrors the messiest real content you have.
Version pin discipline. Last release was early 2024 and the package is small (68 stars). Pin the major version in composer.json and read the changelog before upgrading — the config schema has changed across versions, and a config key moving silently can break rendering in production without an exception.
Escape hatch. If you find yourself fighting the package's config or wrapping every call, switch directly to league/commonmark. You'll lose the Blade component (trivial to reimplement) and the alert syntax (which is a thin extension). Don't let the Laravel-flavoured sugar trap you in a layer you're constantly working around.
How can I help you explore Laravel packages today?