Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Twig Markdown Bundle Laravel Package

hashnz/twig-markdown-bundle

A Symfony bundle that adds Twig filters for rendering Markdown into HTML, letting templates keep content in readable Markdown while outputting formatted HTML through a configurable parser. Install via Composer and register the bundle to add Markdown support to any Symfony application's Twig template...

View on GitHub
Deep Wiki
Context7

Getting Started

The first thing to internalise: hashnz/twig-markdown-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no auto-discovery, no facade. It exists to register Aptoma\Twig\Extension\MarkdownExtension inside a Symfony kernel (TwigMarkdownBundle + DependencyInjection/*Extension + Resources/config/services.yaml). In a Laravel app you will almost certainly never composer require it directly — you will consume the thing it wraps (aptoma/twig-markdown) or its modern equivalent (twig/markdown-extra + league/commonmark).

Where to look first, in this order:

  1. Upstream README: https://github.com/aptoma/twig-markdown — this defines the actual API you care about ({{ post.body|markdown }} and the {% markdown %}…{% endmarkdown %} tag).
  2. The bundle's DependencyInjection/Configuration.php and Resources/config/services.yaml — this is the entire package. It is ~30 lines of wiring. Reading it takes two minutes and tells you exactly what to reimplement.
  3. composer.json on both repos — check the symfony/* and twig/twig constraints. A wrapper at 0 stars / 0 dependents is typically pinned to Twig 1.x/2.x and Symfony 3.x/4.x, which will fight your Laravel 10/11 dependency graph.

Minimal path for a Laravel developer (recommended): skip Twig entirely unless the app already uses it.

composer require league/commonmark
// app/Providers/MarkdownServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;
use League\CommonMark\GithubFlavoredMarkdownConverter;

class MarkdownServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton('markdown', fn () => new GithubFlavoredMarkdownConverter([
            'html_input' => 'strip',        // 'allow' | 'escape' | 'strip'
            'allow_unsafe_links' => false,
        ]));
    }

    public function boot(): void
    {
        Blade::directive('markdown', fn ($expr) =>
            "<?php echo app('markdown')->convert((string) ($expr))->getContent(); ?>");
    }
}

Register it in bootstrap/providers.php (Laravel 11+) or config/app.php. First use case: @markdown($post->body) in a Blade view, or {{ $post->body_html }} if you pre-render. That's it — you have reproduced 100% of this bundle's value with a maintained dependency.

If the app genuinely runs Twig (via rcrowe/twigbridge), then the bundle's job maps onto TwigBridge's extensions.enabled array instead — see below.

Implementation Patterns

Choosing your lane

Approach When Cost
league/commonmark + Blade directive Default for Laravel apps None; maintained, fast
twig/markdown-extra + TwigBridge App already renders with Twig 3 One extra package; official Twig extra
aptoma/twig-markdown + TwigBridge Only if you must match legacy behaviour Manual container wiring, stale dep
Symfony bundle inside Laravel Effectively never Bundle class won't boot; DI won't compile

Pattern A — Blade directive (day-to-day default)

Keep rendering out of the view when the content is user-generated and expensive:

// app/Models/Post.php
protected static function booted(): void
{
    static::saving(function (Post $post) {
        if ($post->isDirty('body')) {
            $post->body_html = app('markdown')->convert($post->body)->getContent();
        }
    });
}

Migration: $table->text('body')->nullable(); $table->longText('body_html')->nullable();

Views become trivial and index pages never re-parse 50 rows of Markdown:

{{-- detail page --}}
<article class="prose">{!! $post->body_html !!}</article>

{{-- anywhere else, render on the fly --}}
<div class="excerpt">@markdown(Str::limit($post->body, 280))</div>

Pattern B — TwigBridge + twig/markdown-extra (if you're on Twig)

// config/twigbridge.php
'extensions' => [
    'enabled' => [
        \Twig\Extra\Markdown\MarkdownExtension::class,
    ],
],

TwigBridge resolves constructor dependencies from the container, so bind the engine:

$this->app->singleton(\Twig\Extra\Markdown\MarkdownInterface::class,
    fn () => new \Twig\Extra\Markdown\LeagueMarkdown(
        new \League\CommonMark\GithubFlavoredMarkdownConverter(['html_input' => 'strip'])
    ));

Then templates use {{ post.body|markdown_to_html }}.

Pattern C — the literal package (only if you inherit it)

If you must keep aptoma's extension, don't install the Symfony bundle. Install the library and register the extension yourself:

composer require aptoma/twig-markdown
// config/twigbridge.php
'extensions' => [
    'enabled' => [\Aptoma\Twig\Extension\MarkdownExtension::class],
],

// AppServiceProvider::register()
$this->app->singleton(\Aptoma\Twig\Extension\MarkdownEngineInterface::class,
    fn () => new \Aptoma\Twig\Extension\MarkdownEngine\MichelfMarkdownEngine());

Integration tips

  • API resources: ship both fields and let the client decide — 'body' => $post->body, 'body_html' => $post->body_html. Never let a mobile client hand-roll a parser.
  • Mail: markdown-to-HTML then wrap in a Blade mail template; don't send raw Markdown to text/plain and text/html from the same string without converting once.
  • Filament / Nova: put the raw Markdown in a Textarea/MarkdownEditor, the rendered HTML in a Placeholder or ViewField.
  • Seeders/factories: use Str::markdown() (Laravel ships league/commonmark wrappers via Illuminate\Support\Str::markdown() and Str::inlineMarkdown()) — no provider needed for fixtures.
  • One config, one place. Define the converter once as a singleton. Two converters with different html_input settings across the app is how you get inconsistent output and a sanitisation hole.

Gotchas and Tips

It is not installable as a Laravel package. There is no Illuminate\Support\ServiceProvider. Composer may pull it in, but nothing boots. If someone added it to composer.json "because it says Twig Markdown", it is dead weight — remove it and take the ~20 lines of wiring into your own provider.

Treat it as unmaintained. 0 stars, 0 dependents, and a wrapper around a library whose own last meaningful activity is years old. Practical consequence: Composer will fail to resolve against Twig 3 / Symfony 6+ / PHP 8.2. If you hit Root composer.json requires ... but these were not found, that's the signal — don't pin your whole framework backwards to satisfy a Markdown filter. Port to twig/markdown-extra (official, uses league/commonmark) or Str::markdown().

XSS is the real risk, not the wiring. Markdown permits inline HTML; **bold** and <script> arrive through the same pipe. Decide explicitly:

  • 'html_input' => 'strip' — safest default for user content.
  • 'html_input' => 'escape' — shows literal tags to the author, useful for docs sites.
  • 'html_input' => 'allow' — trusted authors only; pair with mews/purifier or HTMLPurifier in an after hook.

Also set 'allow_unsafe_links' => false so javascript: and data: URIs in [x](...) are dropped.

Autoescaping will bite you in Twig. The aptoma filter returns a Twig_Markup/Markup object so Twig's escaper leaves it alone; a naive reimplementation returning a plain string gets escaped into &lt;p&gt;. In Blade the mirror-image bug applies: {{ }} escapes, so rendered HTML needs {!! !!} or a directive that echoes raw. Never combine {!! !!} with html_input => 'allow' on untrusted input.

Don't re-parse on every request. $post->body_html in a column (as above) is the single biggest win: list pages of 25 posts go from 25 parser invocations per request to zero. Backfill with an Artisan command and a chunkById loop; add the re-render to a queued job if you have thousands of rows.

Extension ordering and duplicate registration. In TwigBridge, registering both \Twig\Extra\Markdown\MarkdownExtension and \Aptoma\Twig\Extension\MarkdownExtension gives you two filters with overlapping behaviour and confusing precedence. Register exactly one. Debug with:

dump(array_map(fn ($e) => get_class($e), app('twig')->getExtensions()));

Clear the right cache. Rendered-HTML caches live in your DB/Redis; Twig/Blade compiled templates live in storage/framework/views. After changing converter config, php artisan view:clear and re-run your backfill — otherwise you'll be debugging stale output for an hour.

Extension points worth knowing. (1) Register CommonMark extensions (league/commonmark TableExtension, FootnoteExtension, AutolinkExtension, TaskListExtension) on the converter, not in templates — one place, consistent output. (2) Wrap the converter in a MarkdownRenderer service with render() / renderInline() methods so you can later swap engines without touching 40 Blade files. (3) For the {% markdown %} tag (block form), note it renders its body at compile time — dynamic {% set %} values inside it will surprise you; prefer the filter for anything variable.

Test the output, not the parser. Assert on the rendered HTML contract: headings get id anchors (if you enable HeadingPermalinkExtension), tables render, javascript: links are stripped, <script> is removed. A two-line Pest test on a fixture string catches every config regression you'll ever introduce.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager