Native C CommonMark + GitHub Flavored Markdown parser for PHP. ~10-20Γ faster than pure-PHP alternatives (Parsedown, cebe, michelf) on a clean optimized build, targeting CommonMark 0.31 (652/652 spec examples pass; see docs/spec-coverage.md). GFM extensions: tables, strikethrough, task lists, autolinks, tagfilter. Installable via PIE (the PHP Foundation's PECL successor); ships as a single .so. PHP 8.2 minimum, OO API with final classes and readonly options.
# PIE (PHP Foundation's extension installer; uses the composer.json
# at the repo root with type: "php-ext")
pie install iliaal/mdparserOn a minimal PHP image (e.g. php:8.x-cli from Docker Hub), PIE needs a few build tools installed first:
# Debian/Ubuntu
sudo apt install -y git bison libtool-bin
# macOS
brew install bison libtoolgit clone https://github.com/iliaal/mdparser.git
cd mdparser
phpize && ./configure --enable-mdparser
make -j
sudo make install
echo 'extension=mdparser.so' | sudo tee /etc/php/conf.d/mdparser.iniPre-built DLLs for PHP 8.2, 8.3, 8.4, and 8.5 (TS/NTS, x86/x64) are attached to each GitHub release.
use MdParser\Parser;
use MdParser\Options;
// Default parser: safe mode on, GFM extensions on.
$parser = new Parser();
echo $parser->toHtml('# Hello');
// <h1>Hello</h1>
// Custom options via named arguments. All fields readonly.
$parser = new Parser(new Options(
smart: true, // --- -> em dash, -- -> en dash, "..." -> curly
footnotes: true, // enable [^ref] / [^ref]: syntax
unsafe: false, // raw HTML is escaped (default)
));
echo $parser->toHtml($markdown);
// Presets for common setups.
$github = new Parser(Options::github()); // GFM + footnotes + alerts
$strict = new Parser(Options::strict()); // CommonMark, no bare-URL autolinks
$permissive = new Parser(Options::permissive()); // trusted input: raw HTML passes through
// Three output formats from one parser.
$html = $parser->toHtml($markdown);
$xml = $parser->toXml($markdown); // CommonMark XML, DOCTYPE-wrapped
$ast = $parser->toAst($markdown); // nested arrays, see below
// AST shape is documented in tests/006_ast.phpt. Brief example:
// [
// 'type' => 'document',
// 'children' => [
// ['type' => 'heading', 'level' => 1, 'children' => [
// ['type' => 'text', 'literal' => 'Hello'],
// ]],
// ],
// ]Against the major pure-PHP Markdown libraries, on PHP 8.4 (clean optimized build, each parser in its default configuration):
| Corpus | mdparser ops/sec | Best pure-PHP ops/sec | Speedup |
|---|---|---|---|
| 200 B | ~530,000 | ~26,000 (Parsedown) | ~20Γ |
| 1.8 KB | ~110,000 | ~6,000 (cebe/GitHub) | ~19Γ |
| 200 KB | ~980 | ~95 (cebe/GitHub) | ~10Γ |
Against the slowest pure-PHP parser the gap reaches ~45Γ. bench/README.md has the methodology, all parsers, caveats, league/commonmark notes, and reproduction steps. Benchmark a clean optimized PHP build; a debug or ASan build skews the numbers.
Comparison with the major pure-PHP Markdown libraries. "via ext" means the feature exists but requires opting in to a non-default extension; "Extra" means the feature ships in the library's Markdown Extra dialect, not its base mode; "β" means the feature is not supported at all.
| Feature | mdparser | Parsedown | league/cm core | cebe GFM | michelf Extra | Ciconia |
|---|---|---|---|---|---|---|
| CommonMark core | β | partial | β | partial | partial | partial |
| Fenced code blocks | β | β | β | β | β | β |
| GFM tables | β | β | via ext | β | via Extra | β |
| Strikethrough | β | β | via ext | β | β | β |
| Task lists | β | β | via ext | β | β | β |
| Autolinks (bare URL) | β | β | via ext | β | β | β |
<script> tag filter |
β (tagfilter) | β (escaped) | via ext | partial | β | β |
| Smart punctuation | β (Options::smart) |
β | via ext | β | β | β |
| Footnotes | β (Options::footnotes) |
Extra | via ext | β | β Extra | plugin |
| Hardbreaks/nobreaks | β | β | β | β | β | β |
| Sourcepos | β | β | β | β | β | β |
| Heading anchors | β (Options::headingAnchors) |
β | via ext | β | β | β |
rel="nofollow" |
β (Options::nofollowLinks) |
β | via ext | β | β | β |
| HTML output | β | β | β | β | β | β |
| XML output | β | β | β | β | β | β |
| AST output | β (arrays) | β | β (objects) | β | β | β |
md4c also ships dialect extensions beyond CommonMark + GFM. Each one is an opt-in Options flag, off by default: latexMath ($inline$, $$block$$), wikiLinks ([[target]]), spoilers (||text||), underline, highlight (==text==), superscript (^text^), subscript (~text~), admonitions (GitHub-style > [!NOTE] alert blocks), insert (++text++ renders <ins>), and preserveBlankLines (blank-line runs are reported instead of discarded, visible in toXml() and toAst()). Parser-behavior toggles: noIndentedCodeBlocks, permissiveAtxHeadings, collapseWhitespace. See docs/options.md for behavior and edge cases.
mdparser covers CommonMark core plus the GFM extensions. It doesn't implement the "Markdown Extra" features found in Parsedown Extra, michelf Markdown Extra, and league/commonmark's optional extensions. If you need any of the following, use league/commonmark, the most actively maintained pure-PHP option for extended Markdown:
- Definition lists (
Term :: definition) - Abbreviations (
*[HTML]: ...) - Attribute syntax (
{.class #id key="val"}) - Permalink anchor markup (we emit heading
idslugs; we don't inject the inner<a class="anchor">element GitHub uses for permalinks) - Table of contents
- YAML front matter
- Mentions (
@user) - Emoji (
:smile:) - Fenced admonition containers (
::: warning); GitHub-style> [!NOTE]alert blocks are supported viaOptions::admonitions, and are on by default inOptions::github()
The parser's own working memory comes from libc rather than Zend MM, so memory_limit never sees it. In measured release builds, [-dense input used about 76-87 bytes of parser state per input byte, and >-dense input about 45-52 bytes. Without a parse budget, a 16 MiB [-dense input can request more than 1 GiB.
mdparser.parse_memory_limit caps what a single parse may hold, defaulting to 128M. Crossing it throws MdParser\Exception. The setting is PHP_INI_ALL, takes the usual 128M / 1G shorthand, and treats an empty value as the default 128M; explicit 0 or any negative value is unlimited. With the limit off, a 256 MiB [-dense input could ask libc for roughly 19-22 GiB at the measured per-byte rate. Raise the limit if you render very large documents; lower it if you render untrusted Markdown in long-lived workers. Rendered output is separate and stays under memory_limit.
Options::unsafe = true passes raw HTML through verbatim instead of escaping or stripping it. Use it only for input you own or a pipeline you trust. headingAnchors and nofollowLinks run in-stream as md4c parses the source, so they touch only Markdown-derived nodes. Raw HTML is emitted verbatim and never rewritten:
- Heading anchors apply to Markdown headings only. A
# headinggets anidsuch asuser-content-heading; links to generated headings must use the prefixed fragment (#user-content-heading). A raw<h1>x</h1>block (possible underunsafe: true, tagfilter: false) is emitted untouched and gets no id, so it never collides with a later Markdown heading that has the same text. nofollowLinksapplies to Markdown links only. Inline links, reference links, and autolinks getrel="nofollow noopener noreferrer"; in-document fragment anchors (href="#...", including footnote references and backrefs) are skipped. A raw<a href="...">passes through verbatim. Sanitize raw HTML yourself if you allow it.
Parser::toXml() and Parser::toAst() return structural representations of the parsed document. html_block / html_inline literals are preserved byte-for-byte (XML-escaped in toXml()); link and image URLs and titles are entity-decoded but not scheme-filtered. The unsafe, tagfilter, and URL-scheme defenses don't make these outputs safe to turn back into HTML. If you build HTML from XML or AST data, apply a URL scheme allowlist before emitting href, and run html_block / html_inline literal text through a sanitizer. See docs/ast.md for examples.
Companion native PHP extensions:
- php_excel: native Excel I/O via LibXL. 7-10Γ faster than PhpSpreadsheet, full XLS/XLSX with formulas, formatting, and styling.
- php_clickhouse: native ClickHouse client speaking the wire protocol directly. Picks up where SeasClick left off.
- pdo_duckdb: PDO driver for DuckDB, analytical SQL in your PHP stack.
- fastjson: drop-in faster
ext/json, backed by yyjson. 6Γ encode, 2.7Γ decode, 5Γ validate. - phpser: decoder-optimized binary serializer for cache workloads. Faster than igbinary on packed numerics and DTO batches.
- fast_uuid: high-throughput UUID generation (v1/v4/v7), batched CSPRNG and SIMD hex formatter, ramsey-compatible API.
- fastchart: native chart-rendering extension. 38 chart types behind one fluent OO API, SVG-canonical with PNG/JPG/WebP and optional PDF output.
- statgrab: system statistics (CPU, memory, disk, network) via libstatgrab, no parsing /proc by hand.
- phonetic: native phonetic name matching (Double Metaphone, Beider-Morse, Daitch-Mokotoff, NYSIIS, Match Rating), the encoders PHP core lacks.
The launch post covers background, design rationale, and benchmark methodology: mdparser: A Native CommonMark + GFM Parser for PHP.
- Wrapper code (
mdparser*.c,php_mdparser.h) under BSD 3-Clause. - Embedded md4c sources under the MIT license. See
LICENSEfor aggregated notices.
Follow @iliaa on X β’ Blog β’ If this sped up your stack, β star it!
