jralph/twig-markdown
jralph/twig-markdown adds a Markdown filter to Twig, letting you convert Markdown strings to HTML directly in your templates. Simple to install and configure, it keeps content authoring clean and readable while rendering safe HTML output.
First, the honest framing: this is a Twig extension, not a Laravel package. There is no service provider, no facade, no config/twig-markdown.php, and no Blade directive. If you're on a stock Laravel app (Blade + view()), this package gives you nothing until you also adopt Twig — typically via rcrowe/twigbridge. That's the single most important thing to know before installing it. The realistic first use case is a Laravel app (or a non-Laravel PHP app) that already renders Twig templates and needs to turn stored Markdown — blog bodies, CMS pages, changelogs, user bios — into HTML inside those templates.
Install:
composer require jralph/twig-markdown
It pulls in a Markdown parser (Parsedown / Parsedown-Extra in the versions I've seen) as a transitive dependency, and it requires a compatible Twig version — check composer.json before assuming it installs cleanly (see Gotchas).
Where to look first: open vendor/jralph/twig-markdown/src/ and read the Extension class. It is small — a few hundred lines at most. The important things to confirm are the filter name (typically markdown), whether the extension returns a Twig\Markup (safe) object or a plain string, and whether the constructor accepts an adapter/parser argument you can swap. That single file tells you 90% of what you need.
Register it (standalone Twig):
$twig = new \Twig\Environment($loader);
$twig->addExtension(new \Jralph\Twig\Markdown\Extension());
Register it (Laravel + TwigBridge):
// config/twigbridge.php
'extensions' => [
// ...
\Jralph\Twig\Markdown\Extension::class,
],
First template use:
{{ post.body|markdown }}
If your content comes out escaped (you see <p> in the page source), your Twig version mismatch means the extension is returning a plain string instead of safe markup — add |raw and move on:
{{ post.body|markdown|raw }}
That's the whole loop: filter a string, get HTML.
The bread-and-butter pattern — render stored Markdown in Twig views. Keep raw Markdown in the database (text/longtext column), render at read time. This is the simplest and most flexible approach because you can always re-render if you change parsers or fix formatting bugs.
<article>
<h1>{{ post.title }}</h1>
<div class="prose">{{ post.body|markdown|raw }}</div>
</article>
Render once, cache the HTML. Parsing Markdown on every request is wasted CPU, especially for long posts. The day-two optimization in a Laravel app is to cache the rendered result:
// App\Models\Post
public function renderedBody(): string
{
return Cache::rememberForever("post:{$this->id}:html", function () {
$twig = app(\Twig\Environment::class);
// or, better, parse with the same parser directly
return app(\Jralph\Twig\Markdown\Extension::class)->parseMarkdown($this->body);
});
}
If the extension doesn't expose a clean public parse method, either render it through a tiny Twig template or drop down to the underlying Parsedown instance directly — they're equivalent output, and caching is worth the small coupling.
Compose with other filters carefully. Order matters and Markdown output is HTML, so anything after |markdown operates on HTML, not text:
{# Truncate the SOURCE, then render — safe for sentences, can break tables/code blocks #}
{{ post.body|slice(0, 500) ~ '…'|markdown|raw }}
{# Render, then strip — good for meta descriptions and previews #}
{{ post.body|markdown|striptags|slice(0, 160) }}
Rule of thumb: striptags and slice after |markdown for excerpts and meta descriptions; never truncate the source if the content may contain fenced code or tables, because you'll emit unbalanced HTML.
Use it as a content pipeline in non-view contexts. In a Twig-first app you'll often have console commands, mail builders, or static-site generators that render Twig. In those contexts you register the extension once at bootstrap and reuse it:
final class TwigFactory
{
public static function make(): \Twig\Environment
{
$twig = new \Twig\Environment(
new \Twig\Loader\FilesystemLoader(resource_path('twig'))
);
$twig->addExtension(new \Jralph\Twig\Markdown\Extension());
return $twig;
}
}
Integrating in Laravel with TwigBridge — wire it once in config, then forget it. Add the class to extensions, then all views have markdown available. Avoid registering the extension in a service provider manually and in config; you'll get "extension already registered" errors. Pick one path.
Consider wrapping it in a Blade-friendly shim if you're migrating. If your Laravel team is 95% Blade and dipping into Twig for a legacy section, define a Blade directive that runs the same underlying parser, so templates don't diverge:
// AppServiceProvider::boot()
Blade::directive('markdown', function ($expr) {
return "<?php echo e(app('markdown')->text({$expr})); ?>";
});
Then @markdown($post->body) and {{ post.body|markdown }} stay in sync — same parser, same output, no double implementations.
Extension point — swap the parser via an adapter. If the extension exposes adapters (Parsedown, ParsedownExtra, League CommonMark in newer forks — verify in the source), construct with the one that matches your content needs. ParsedownExtra adds tables and footnotes; CommonMark is the modern, spec-compliant choice. If the extension only supports one, subclass the Extension class and override the parse method to delegate to league/commonmark — a 20-line adapter that keeps the Twig filter API intact while modernizing the parser underneath.
Maintenance risk is the headline issue. Last release was 2019, 11 stars, zero dependents, and the parser it wraps (Parsedown) is itself effectively unmaintained. For any project with a multi-year horizon, treat this package as a template for a filter you'll own rather than a dependency you can rely on. The good news: it's tiny, so vendoring it or copying its extension class into your app is a five-minute task.
Twig version compatibility is the most likely install blocker. Extensions written against Twig 1 (extends \Twig_Extension) break on Twig 3 (where the class is Twig\Extension\AbstractExtension). The 2019 release date puts it squarely in the "Twig 1/2 only, workarounds on Twig 3" era. Check composer.json for twig/twig constraints and PHP constraints before you commit; Composer will fail loudly on a modern stack, which is a feature, not a bug.
No service provider means no auto-discovery. In a Laravel app, don't expect php artisan vendor:publish to yield anything. Everything is manual: package install, Twig bootstrap, extension registration. Budget an hour for wiring if you've never used TwigBridge.
XSS is your responsibility, and it's easy to get wrong. Parsedown historically allows raw HTML in Markdown by default. If Markdown comes from users, you must sanitize after rendering — |markdown|raw on untrusted input is a stored-XSS vector. Use HTMLPurifier or League's CommonMark + HtmlFilter extension:
// League CommonMark with sanitization — the modern pattern
$converter = new \League\CommonMark\CommonMarkConverter([
'html_input' => 'strip', // kill raw HTML
'allow_unsafe_links' => false,
]);
echo $converter->convert($userMarkdown);
Only allow raw HTML for trusted authors (your own CMS editors), never for public submissions.
Escaping bites in two directions. If the extension returns a Twig\Markup, {{ x|markdown }} is already unescaped and |raw is redundant (harmless, but signals confusion). If it returns a plain string — which can happen on version mismatches or when the extension is registered incorrectly — you'll see literal <p>. Debug by dumping the type: {{ post.body|markdown|length }} vs. rendering {{ post.body|markdown }} and checking the page source for <.
Truncating Markdown source produces broken HTML. slice(0, 300) on a post containing a fenced code block or an unclosed <table> yields malformed markup that can break your layout. Always strip/truncate after rendering for excerpts. If you need to preserve Markdown semantics, use an HTML-aware truncator (e.g. spatie/laravel-html or HtmlTruncator).
Symfony/Twig cache invalidation is separate from Laravel's. Twig has its own compiled-template cache directory. If you swap the extension or change its configuration, you need to clear the Twig cache, not just php artisan view:clear:
rm -rf storage/framework/twig/*
Debugging "my change didn't apply" almost always lands here.
Caching rendered HTML is a correctness lever, not just performance. If you cache, remember to bust the cache on content updates. The pragmatic pattern is to cache keyed on post.updated_at ("post:{$id}:{$updatedAt->timestamp}:html") so stale HTML can't survive a save.
If you're not already on Twig, don't adopt Twig for this. In a Blade Laravel app, the right equivalents are graham-campbell/markdown (wraps League CommonMark with a facade and Blade-ready helpers) or spatie/laravel-markdown. Adopting TwigBridge solely to use an eleven-star, unmaintained Markdown filter is a poor trade — you'd be taking on Twig's template-cache, escaping, and extension-compatibility surface for one filter.
Verdict for a Laravel dev: treat jralph/twig-markdown as a five-line extension template, not a dependency. Read its Extension class, copy the pattern into your own App\Twig\MarkdownExtension, point it at league/commonmark with sanitization on, and you get the same Twig ergonomics without the 2019 compatibility and maintenance baggage. Use the package directly only if you're already committed to Twig and just need a filter this afternoon.
How can I help you explore Laravel packages today?