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

Commonmark Bundle Laravel Package

yivoff/commonmark-bundle

A Symfony bundle that wraps the league/commonmark Markdown parser, providing a configurable CommonMark converter service and Twig filter for rendering Markdown in templates. It simplifies setup for projects that need consistent Markdown-to-HTML conversion.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: it's a Symfony bundle, not a Laravel package. yivoff/commonmark-bundle ships a Symfony Bundle class, a DependencyInjection extension, and a YAML config tree. Composer will install it in a Laravel app without complaint, but Laravel has no bundle auto-discovery: no service provider is registered, no config is merged, and none of the "multiple named converters" wiring exists. You get the vendor/ source and nothing else.

So there are two honest paths for a Laravel developer:

  1. You're actually in a Symfony 5 app (or maintaining one) — install and use the bundle directly.
  2. You're in Laravel — you want the pattern, not the package. The underlying library, league/commonmark v2, is framework-agnostic and excellent. Reimplement the ~80 lines of config plumbing yourself (see Implementation Patterns). This is almost always what you want for a 2-star, single-release, 2021-era bundle with zero dependents.

Path 1, first use case (Symfony):

composer require yivoff/commonmark-bundle
# config/packages/yivoff_commonmark.yaml
yivoff_commonmark:
    converters:
        strict:                 # user-submitted comments
            config:
                html_input: strip
                allow_unsafe_links: false
        editorial:              # trusted CMS content
            config:
                html_input: allow

Inspect what actually got registered before writing code against it — the service ids are the contract, and the README is the only documentation:

php bin/console debug:container yivoff --show-private
php bin/console config:dump-reference yivoff_commonmark

Path 2, first use case (Laravel): the same split — one hardened converter for untrusted comment bodies, one permissive converter for editorial markdown that legitimately contains raw HTML and tables. Bind them in a service provider and inject by name.

Where to look first, either way: league/commonmark's own docs. The bundle adds nothing to the conversion semantics; it only lets you declare N converters and their configs side by side. Every real question you'll have (extension names, config keys, Environment construction) is answered upstream.

Implementation Patterns

The core idea worth stealing: named converters

One markdown renderer for a whole app is a security anti-pattern. You want a small registry — strict, editorial, gfm, email — each with its own config and extension set, selected at the call site.

Replicating the bundle in Laravel

// config/commonmark.php
return [
    'converters' => [
        'strict' => [
            'config' => [
                'html_input' => 'strip',
                'allow_unsafe_links' => false,
                'max_nesting_level' => 20,
            ],
            'extensions' => [
                \League\CommonMark\Extension\Autolink\AutolinkExtension::class,
            ],
        ],
        'editorial' => [
            'config' => ['html_input' => 'allow', 'allow_unsafe_links' => false],
            'extensions' => [
                \League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension::class,
                \League\CommonMark\Extension\Table\TableExtension::class,
                \League\CommonMark\Extension\Footnote\FootnoteExtension::class,
                \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension::class,
                \League\CommonMark\Extension\Attributes\AttributesExtension::class,
            ],
        ],
    ],
];
// app/Support/CommonMark/ConverterManager.php
namespace App\Support\CommonMark;

use League\CommonMark\Environment\Environment;
use League\CommonMark\MarkdownConverter;

class ConverterManager
{
    /** @var array<string, MarkdownConverter> */
    private array $resolved = [];

    public function __construct(private array $converters) {}

    public function converter(string $name = 'strict'): MarkdownConverter
    {
        return $this->resolved[$name] ??= $this->build($name);
    }

    public function convert(string $markdown, string $name = 'strict'): string
    {
        return (string) $this->converter($name)->convert($markdown);
    }

    private function build(string $name): MarkdownConverter
    {
        $definition = $this->converters[$name]
            ?? throw new \InvalidArgumentException("Unknown CommonMark converter [{$name}].");

        // v2: extensions go into the Environment constructor, not ->addExtension()
        $extensions = array_map(
            fn (string $class) => app($class),
            $definition['extensions'] ?? [],
        );

        $environment = new Environment($definition['config'] ?? [], $extensions);

        return new MarkdownConverter($environment);
    }
}
// app/Providers/CommonMarkServiceProvider.php
public function register(): void
{
    $this->app->singleton(ConverterManager::class, fn ($app) => new ConverterManager(
        $app['config']->get('commonmark.converters', [])
    ));
}

public function boot(): void
{
    // Blade helper — returns Htmlable so `{{ markdown($body) }}` isn't double-escaped
    Blade::directive('markdown', fn ($expr) => "<?php echo app(\\App\\Support\\CommonMark\\ConverterManager::class)->convert({$expr}, 'editorial'); ?>");
}

Choosing a converter at runtime

The useful selection axes, in rough order of how often they come up:

  • Trust boundary — auth()->user()?->is_staff() or a per-field flag decides strict vs editorial. Never let the request decide.
  • Surface — comments use strict; long-form CMS pages use editorial; README/issue-style text uses gfm; transactional email bodies use a converter with no HeadingPermalinkExtension (permalink anchors look broken in email clients).
  • Output target — HTML for pages, plain text for SMS/push ($converter->convert($md)->getContent() then strip tags), and Markdown→Markdown normalization for search indexes.

Encapsulate that decision once:

public function forDocument(Document $doc): string
{
    return $this->convert($doc->body, $doc->is_public_editorial ? 'editorial' : 'strict');
}

Cache the rendered HTML, not the parse

Conversion is cheap; doing it 40 times per page in an index listing is not. Cache the output, keyed on content hash plus converter identity plus a version salt:

$html = Cache::remember(
    "md:{$name}:" . sha1($markdown) . ':' . config('commonmark.version'),
    now()->addDay(),
    fn () => $manager->convert($markdown, $name),
);

Include the converter name and a config version constant in the key. When you add TableExtension to editorial and forget to bump the salt, you get a week of "the tables are still broken, boss."

Sanitize whenever html_input is anything but strip

html_input: allow is the library default, which means a half-configured converter is an XSS vector out of the box. If editorial content must carry raw HTML, pass the result through HTML Purifier before persisting or before output — belt and braces, since editors get phished too.

Integration points worth wiring up

  • API resources — expose both body_markdown (source of truth, for round-tripping and mobile re-render) and body_html (rendered, cache-hinted via ETag on the markdown hash).
  • Model casts — don't cast a body column to HTML; keep the column raw and render in an accessor or presenter so a config change re-renders everything.
  • Filament / Nova fields — split-pane preview where the preview hits your server-side converter via a tiny endpoint, guaranteeing the editor and the front end agree.
  • Search indexing — index the rendered-then-stripped text, not the Markdown; ## and [link](url) poison full-text relevance.

Gotchas and Tips

The bundle will not boot in Laravel. No composer.json extra.laravel.providers, no Illuminate\Support\ServiceProvider subclass. If you composer require it in a Laravel app you'll pull the dependency tree and use none of the wiring. Vendor the ~80 lines instead; you'll understand them better than you'd understand a 2-star dependency from 2021.

Abandonment risk is real and quantifiable. One release, August 2021, Symfony 5 constraint, 0 dependents, 2 stars. That's not a package you build a content pipeline on. league/commonmark itself is actively maintained and v2 is stable — depend on it directly and keep the config layer as your own code. If you're in Symfony and want a maintained wrapper, look at Symfony's own or write the compiler pass yourself; it's an afternoon.

Version constraint friction. The bundle's composer.json pins symfony/*: ^5.0 and league/commonmark: ^2.0. On Symfony 6/7 you'll hit resolution errors and end up with --ignore-platform-reqs or an alias hack — both of which mean you're now maintaining a fork. On the Laravel side you likely have commonmark v2 pulled in by something else (Filament, Nova, spatie/laravel-markdown), so check composer why league/commonmark before adding anything.

html_input defaults to allow. If you set only allow_unsafe_links: false and call it done, you've blocked javascript: hrefs but still pass <script> and <img onerror>. Set html_input: strip or escape for anything user-authored — and understand the difference: strip deletes the raw HTML block, escape renders it as visible text. For comment bodies, strip produces confusing output ("why did my angle brackets vanish?"); escape is usually the kinder default.

max_nesting_level. Deeply nested blockquotes/lists are a quadratic-ish parser blowup and a trivial DoS vector. Set it (v2 default is 0 = unlimited in some paths; set 20–50 explicitly).

v1 config keys leak into v2 tutorials. Every blog post from 2020 uses renderer => ['block_separator' => ...], enable_em, commonmark sub-arrays. In v2 those are top-level keys (html_input, allow_unsafe_links, max_nesting_level) and extensions moved into the Environment constructor. If your config "silently does nothing," it's a v1 key.

Extensions must be classes or resolved instances, not strings, in the Environment constructor. The bundle resolves extension service ids for you; your Laravel port needs app($class). Getting new Environment($config, ['League\...\TableExtension']) throws a type error that isn't obvious at the call site.

Symfony config merging surprises. In the bundle, converters entries merge across config/packages/* and config/packages/{env}/*. Adding test: overrides can leave you with two converters where you expected one replaced, and the resulting service id collision resolves silently to the merged config. config:dump-reference and debug:container are your only real visibility.

Debugging a converter. In Symfony: php bin/console debug:container yivoff then dump(app('yivoff...')->getEnvironment()->getExtensions()) — the extension list is where 90% of "wrong output" bugs live (missing TableExtension looks like Markdown that "doesn't work"). In Laravel: php artisan tinker and dump $manager->converter('editorial')->getEnvironment().

Test with golden files, not vibes. Store tests/fixtures/markdown/*.md with expected HTML siblings. Add one assertion that every strict output contains no <script, <iframe, onerror=, or javascript: — that's the test that actually earns its keep. assertStringNotContainsString against a corpus of 20 known-bad snippets is a five-minute, forever-useful safety net.

Escape hatches. Both the Symfony bundle and any sane reimplementation let you reach the Environment (register custom inline parsers, delimiters, renderers) and the raw MarkdownConverter. Keep an EnvironmentConfigurator hook in your manager so you can add a one-off extension for a single document type without editing global config. That's the extension point you'll actually need six months in — the bundle's own extension points (tagged services) are undocumented, so don't build on them.

Don't render on write and on read. Pick one. Rendering on write (body_html column) is fast to read but stale after any config change and painful to backfill; rendering on read with a cache is the better default because config changes propagate for free. Whichever you choose, write it down — mixed conventions across a codebase are how you get two comment lists that format differently.

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.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json