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 Markdown Renderer Laravel Package

wnx/commonmark-markdown-renderer

A PHP package that renders a league/commonmark AST back to Markdown, letting you parse Markdown, manipulate the document tree, and re-render it. Note that alternate Markdown syntaxes mean input and output may differ.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify constraints first. This package has no service provider, no config file, no facade — it is a plain Composer library that plugs into league/commonmark. That means the only real installation risk is dependency resolution:

composer require wnx/commonmark-markdown-renderer
composer show league/commonmark wnx/commonmark-markdown-renderer

Check the league/commonmark constraint against what your app already pins (most Laravel apps pull CommonMark in for HTML rendering). If your app is on ^2.x you're fine; if anything forces a bump, resolve that before writing code. The package requires PHP 8.1+ (8.0 dropped in v1.3.0) and currently supports up to PHP 8.5.

The minimum viable bootstrap — note you build two objects from one Environment:

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Parser\MarkdownParser;
use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension;
use Wnx\CommonmarkMarkdownRenderer\Renderer\MarkdownRenderer;

$environment = new Environment([
    'html_input' => 'allow',
    'allow_unsafe_links' => true,
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new MarkdownRendererExtension());

$parser   = new MarkdownParser($environment);
$renderer = new MarkdownRenderer($environment);

$ast = $parser->parse($markdown);
$markdown = $renderer->renderDocument($ast);

Where to look first. The whole package is small enough to read in one sitting, and you should, because the README does not document coverage:

  • src/MarkdownRendererExtension.php — what gets registered and how.
  • src/Renderer/MarkdownRenderer.php — the entry point, renderDocument().
  • src/Renderer/* — one renderer class per node type. This directory is your coverage matrix. Enumerate the files; those are the node types you can round-trip. Anything not in there is a question mark.
  • tests/ — the fixtures double as a fidelity spec. Skim them before you trust the package on your corpus.

First use case: a normalizer command. The natural day-one task is an artisan command that reads a markdown file, parses it, and writes it back — giving you an immediate, measurable diff against real content:

$ast = $parser->parse(File::get($path));
File::put($path.'.out.md', $renderer->renderDocument($ast));

Run that on 20–30 real documents from your repo/docs folder and git diff the results. That single exercise tells you more about whether this package fits than anything else in the README.

Implementation Patterns

Wrap it in a small internal service and bind it once. Never let Wnx\ namespaces leak across your codebase. One class, one container binding in AppServiceProvider, and the vendor package becomes swappable (or forkable) without touching call sites:

namespace App\Support\Markdown;

final class MarkdownRoundTrip
{
    private MarkdownParser $parser;
    private MarkdownRenderer $renderer;

    public function __construct(array $config = [])
    {
        $environment = new Environment($config + [
            'html_input' => 'allow',
            'allow_unsafe_links' => true,
        ]);
        $environment->addExtension(new CommonMarkCoreExtension());
        // add GFM, tables, etc. here — same extensions used for HTML rendering
        $environment->addExtension(new MarkdownRendererExtension());

        $this->parser   = new MarkdownParser($environment);
        $this->renderer = new MarkdownRenderer($environment);
    }

    public function transform(string $markdown, callable $mutate): string
    {
        $ast = $this->parser->parse($markdown);
        $mutate($ast);
        return $this->renderer->renderDocument($ast);
    }
}

Register it as a singleton so the environment is built once per request/worker, not per document — important if you loop over thousands of files in a job.

The core pattern is parse → mutate → render, and the mutation is the whole point. The package earns its keep only when you're doing structural edits that string manipulation can't reliably do. Representative recipes:

use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Node\NodeIterator;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;

// Prepend a generated notice to the document
$notice = new Paragraph();
$notice->appendChild(new Text('> Auto-generated. Do not edit directly.'));
$ast->prependChild($notice);

// Rewrite every link's URL
foreach (new NodeIterator($ast) as $node) {
    if ($node instanceof Link) {
        $node->setUrl(str_replace('old.example.com', 'new.example.com', $node->getUrl()));
    }
}

// Drop every top-level section under a given heading — walk siblings, detach until next heading

Walking with NodeIterator (or NodeWalker) plus detach() / appendChild() / insertAfter() covers the overwhelming majority of real transformation work. This is where the package replaces fragile preg_replace chains in a CMS.

Use it for content migrations and bulk rewrites. A queued job that loads a Post model, transforms body, and saves is the classic shape. Two rules: (1) always support a --dry-run that writes the rendered output next to the original for diffing instead of saving; (2) run it on a shadow column or via a feature flag before touching body in place, because the round trip is lossy.

Guard idempotency with a test. Since output is not byte-identical to input, the property you actually care about is stability: rendering the rendered output again should produce the same bytes. That's a one-line Pest/PHPUnit test that costs nothing and catches renderer regressions on upgrade:

it('is idempotent', function () {
    $once  = $service->transform($input, fn () => null);
    $twice = $service->transform($once, fn () => null);
    expect($twice)->toBe($once);
});

Feed it a corpus fixture set — headings, nested lists, task lists, fenced code with language hints, indented code, tables, links, reference-style links, raw HTML blocks, footnotes. This test is your early-warning system when you bump the package or CommonMark.

Separate environments for HTML and Markdown output. Don't reuse the HTML rendering environment just because it works. HTML rendering and Markdown rendering have different concerns (html_input escaping semantics in particular), and a config tweak aimed at one will silently change the other. Two small factories, one config array each.

Extension registration must match on both legs. If your app parses markdown with GFM tables / strikethrough / footnotes for HTML output, those same extensions must be on the environment you use for AST round-tripping — otherwise those constructs are never parsed into nodes, and the renderer has nothing to render. The output will look like the tables were flattened, which is a parse-time problem, not a renderer bug.

Gotchas and Tips

This is not a byte-preserving round trip, and you must design around that. The README says it explicitly: alternate syntaxes for the same element mean input and output differ. *em* may come back as _em_, --- and *** both become whatever the renderer picks, Setext headings become # headings. Anything that depends on content hashing, line-level diffs, or "only changed lines show up in the PR" is off the table for the first pass. After the first normalization pass, subsequent passes should be stable — verify that with the idempotency test above rather than assuming it.

Front matter is not in the AST. YAML/TOML front matter isn't a CommonMark construct. Depending on the config it'll be dropped entirely or mangled into a thematic break plus paragraph. Split front matter off before parsing and glue it back after. Same for any custom templating syntax (Blade directives, shortcodes) embedded in the markdown — they're just text to the parser and will be escaped or reflowed.

Text nodes are escaped on render. Anything you inject as a Text node gets markdown-escaped (*, _, [, backticks). That's correct and desirable, but it bites when you programmatically insert something you want interpreted as markdown — a link, bold text. Build the actual node (new Link($url, $label), new Strong()) rather than stuffing markdown syntax into a Text node. Code blocks are the documented exception (fixed in v1.0.2 — content is not escaped).

Unknown node types are the main unknown. The renderer only knows about the node types it ships renderers for. Custom nodes from your own CommonMark extensions, or GFM features beyond task lists, are where round-tripping goes silent or throws. Audit this explicitly: register your extensions, parse representative content, render, and diff. If a type is missing, src/Renderer/ is the extension point — add a renderer class and register it via the extension. Given MIT license and a tiny surface, patching locally or vendoring is a realistic fallback; decide which up front rather than at 2am during a migration.

Pin the version, don't float ^. Nine stars, zero dependents, single maintainer. The maintainer is credible and releases are dated and reasonably current, but you are effectively the only one exercising this code in production. Pin exactly, upgrade deliberately, and keep the idempotency test running so an upgrade can't silently change output.

Watch the league/commonmark version, not this package's version. Almost all behavioural change you'll observe comes from the underlying parser, not this renderer. When output shifts after a composer update, check the CommonMark changelog first.

List and code-block rendering has historically been the rough edge — multi-line list items (v1.3.1), fenced block fidelity (v1.4.1), indented code blocks (v1.4.1), list content preservation (v1.4.1). If you're on an older tag, upgrade before you evaluate; if you're on current, these are your canary fixtures.

Debug by dumping the AST, not the output. When output looks wrong, the fault is usually in construction or parsing, not serialization. NodeIterator over the document and print class names + getLiteral()/getUrl() for each node — that tells you whether you inserted the node you thought you did.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core