php-markdown / parser
Lightweight standalone CommonMark Markdown to HTML5 parser — no external dependencies
Requires
- php: >=8.2
- ext-ctype: *
- ext-intl: *
- ext-mbstring: *
Requires (Dev)
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11
- vimeo/psalm: 6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.1
- 1.0.0
- dev-docs/readme-benchmark
- dev-perf/inline-fast-paths
- dev-perf/renderer
- dev-fix/table-padding-limit
- dev-perf/inline-quadratic
- dev-perf/lexer-prefilters
- dev-docs/readme-sync
- dev-fix/url-validator-host-port-scheme
- dev-fix/inline-raw-html-sanitizer
- dev-feat/leading-spaces-block-markers
- dev-perf/fast-paths
- dev-fix/empty-list-items
- dev-fix/mixed-nested-lists
- dev-fix/size-limit-after-normalization
- dev-refactor/shared-url-validator
- dev-fix/consistent-xhtml-output
- dev-fix/list-items
- dev-fix/main-blockquote-fatal
- dev-fix/blockquote-lazy-continuation
- dev-fix/link-definitions-in-containers
- dev-fix/inline-links
- dev-fix/block-unsafe-link-schemes
- dev-fix/table-detection
- dev-fix/atx-headings
- dev-fix/emphasis-parsing-dos
- dev-fix/sanitize-inline-raw-html
- dev-ci/run-security-test-suite
- dev-feat/tab-expansion
- dev-feat/html-entity-decoding
- dev-feat/soft-line-breaks
- dev-feat/xhtml-renderer-output-format
- dev-feat/setext-heading-multiline
- dev-fix/mermaid-edge-label-token-array
- dev-feat/atx-heading-empty-content
- dev-fix/thematic-breaks-with-spaces
This package is auto-updated.
Last update: 2026-10-31 00:54:08 UTC
README
A lightweight, standalone PHP library (8.2–8.4) that parses CommonMark-flavored Markdown and converts it into clean, semantic HTML5. Zero runtime dependencies.
Requirements
- PHP 8.2 to 8.4
- PHP extensions:
ctype,intl,mbstring - No external packages required for core usage
Installation
Via Composer (recommended)
composer require php-markdown/parser
Manual (no Composer)
Copy the src/ directory into your project, then require the files in this order:
require '/path/to/src/Exception/ParseException.php'; require '/path/to/src/Node/NodeInterface.php'; require '/path/to/src/Node/BlockNodeInterface.php'; require '/path/to/src/Node/InlineNodeInterface.php'; require '/path/to/src/Node/Block/DocumentNode.php'; require '/path/to/src/Node/Block/HeadingNode.php'; require '/path/to/src/Node/Block/ParagraphNode.php'; require '/path/to/src/Node/Block/BlockquoteNode.php'; require '/path/to/src/Node/Block/ListNode.php'; require '/path/to/src/Node/Block/ListItemNode.php'; require '/path/to/src/Node/Block/FencedCodeNode.php'; require '/path/to/src/Node/Block/IndentedCodeNode.php'; require '/path/to/src/Node/Block/HorizontalRuleNode.php'; require '/path/to/src/Node/Block/TableNode.php'; require '/path/to/src/Node/Block/TableRowNode.php'; require '/path/to/src/Node/Block/TableCellNode.php'; require '/path/to/src/Node/Block/RawHtmlBlockNode.php'; require '/path/to/src/Node/Block/ColumnsNode.php'; require '/path/to/src/Node/Block/FootnoteDefinitionNode.php'; require '/path/to/src/Node/Block/FootnotesContainerNode.php'; require '/path/to/src/Node/Inline/TextNode.php'; require '/path/to/src/Node/Inline/EmphasisNode.php'; require '/path/to/src/Node/Inline/StrongNode.php'; require '/path/to/src/Node/Inline/StrikethroughNode.php'; require '/path/to/src/Node/Inline/CodeNode.php'; require '/path/to/src/Node/Inline/LinkNode.php'; require '/path/to/src/Node/Inline/ImageNode.php'; require '/path/to/src/Node/Inline/AutolinkNode.php'; require '/path/to/src/Node/Inline/HardBreakNode.php'; require '/path/to/src/Node/Inline/SoftBreakNode.php'; require '/path/to/src/Node/Inline/HtmlEntityNode.php'; require '/path/to/src/Node/Inline/RawHtmlInlineNode.php'; require '/path/to/src/Node/Inline/FootnoteRefNode.php'; require '/path/to/src/Normalizer/NormalizerInterface.php'; require '/path/to/src/Normalizer/IcuNormalizer.php'; require '/path/to/src/Sanitizer/UrlValidator.php'; require '/path/to/src/Sanitizer/HtmlSanitizer.php'; require '/path/to/src/Lexer/TokenType.php'; require '/path/to/src/Lexer/Token.php'; require '/path/to/src/Lexer/TableCells.php'; require '/path/to/src/Lexer/Lexer.php'; require '/path/to/src/Parser/DelimiterRun.php'; require '/path/to/src/Parser/DelimiterEntry.php'; require '/path/to/src/Parser/FlankingComputer.php'; require '/path/to/src/Parser/DelimiterStack.php'; require '/path/to/src/Parser/InlineParser.php'; require '/path/to/src/Parser/Parser.php'; require '/path/to/src/Parser/FrontMatterParser.php'; require '/path/to/src/Renderer/HtmlRenderer.php'; require '/path/to/src/MarkdownParser.php';
Try it — Docker Playground
No PHP installation required. From the project root:
docker-compose up --build
Open http://localhost:8080 in your browser. See playground/README.md
for full setup details, port-override instructions, and caveats.
Quick Start
<?php declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use PhpMarkdown\MarkdownParser; $parser = new MarkdownParser(); $html = $parser->parse('# Hello **World**'); echo $html; // <h1>Hello <strong>World</strong></h1>
Front Matter
$markdown = <<<MD --- title: My Post published: true tags: php --- # My Post Content here. MD; ['html' => $html, 'meta' => $meta] = $parser->parseWithMeta($markdown); echo $meta['title']; // My Post echo $meta['published']; // true (bool) echo $html; // <h1>My Post</h1>\n<p>Content here.</p>\n
parse() also accepts front matter — it strips the block silently, so existing code needs no changes.
GFM Tables
$markdown = <<<MD | Name | Score | |:------|------:| | Alice | 95 | | Bob | 87 | MD; echo $parser->parse($markdown); // <table> // <thead> // <tr> // <th align="left">Name</th> // <th align="right">Score</th> // </tr> // </thead> // <tbody> // <tr> // <td align="left">Alice</td> // ...
Raw HTML pass-through
Raw HTML is escaped by default (XSS-safe). Opt in to sanitized pass-through with allowRawHtml: true:
$html = $parser->parse('<div class="note">text</div>', allowRawHtml: true); // <div class="note">text</div> — dangerous tags/attrs stripped by HtmlSanitizer
Options
All constructor arguments are optional and named:
$parser = new MarkdownParser( maxBytes: 1_048_576, // default; larger input throws ParseException (checked before and after NFC normalization) linkTarget: '_blank', // adds target="…" to every rendered link and autolink linkRel: 'nofollow', // adds rel="…"; "noopener" is appended automatically when target is _blank ); echo $parser->parse('[a](/x)'); // <p><a href="/x" target="_blank" rel="nofollow noopener">a</a></p>
A custom NormalizerInterface can also be passed as normalizer: (defaults to IcuNormalizer).
Supported Features
Block elements
| Feature | Syntax | Output |
|---|---|---|
| Headings H1–H6 (ATX) | # Heading … ###### Heading (empty content allowed: # ) |
<h1> … <h6> |
| Headings H1–H2 (Setext) | Heading\n=== / Heading\n--- (multiline content supported) |
<h1> / <h2> |
| Paragraph | plain text (soft line breaks preserved as newlines) | <p> |
| Blockquote (nested) | > text |
<blockquote> |
| Unordered list | - item / * item / + item |
<ul><li> |
| Ordered list | 1. item / 1) item (start number kept: 3. item) |
<ol><li> / <ol start="3"> |
| Nested lists | indented - item inside list item |
<ul> inside <li> |
| Task list | - [x] done / - [ ] todo |
<li><input type="checkbox" …> |
| Fenced code block | ```lang … ``` or ~~~lang … ~~~ |
<pre><code class="language-*"> |
| Mermaid diagram | ```mermaid … ``` |
<div class="mermaid"> |
| Indented code block | 4-space / 1-tab indent | <pre><code> |
| Horizontal rule | --- / *** / ___ |
<hr /> |
| GFM Table | | col | col | + separator row |
<table><thead><tbody> with align |
| Front Matter | ---\nkey: value\n--- at file top |
parsed into meta array via parseWithMeta() |
| Raw HTML block | <div>…</div> |
escaped (default) or sanitized (allowRawHtml: true) |
| Two-column layout | :::columns … ||| … ::: |
<div class="grid grid-cols-2 …"> |
| Footnote definition | [^label]: body |
rendered in <section class="footnotes"> |
Inline elements
| Feature | Syntax | Output |
|---|---|---|
| Bold | **text** or __text__ |
<strong> |
| Italic | *text* or _text_ |
<em> |
| Strikethrough | ~~text~~ |
<del> |
| Inline code | `code` |
<code> |
| Link (inline) | [text](url) or [text](<url>), optional title in "…", '…' or (…) |
<a href="…"> |
| Link (reference) | [text][ref] with [ref]: url or [ref]: <url> definition |
<a href="…"> |
| Image | ,  or ![alt][ref] |
<img src="…" alt="…" /> |
| Autolink | <https://…> or <user@example.com> |
<a href="…"> |
| Hard line break | two trailing spaces + newline | <br /> |
| HTML entity / numeric reference | ©, *, * |
decoded to the character, then re-escaped |
| Backslash escape | \*not emphasis\* |
literal punctuation |
| Raw HTML inline | <span class="x"> |
escaped (default) or sanitized (allowRawHtml: true) |
| Footnote reference | [^label] |
<sup><a href="#fn-…"> |
Architecture
The library is built as a three-stage pipeline. Each stage is independent and testable in isolation.
Input string
│
▼
┌─────────┐ Token[] ┌────────┐ DocumentNode ┌──────────┐
│ Lexer │ ──────────► │ Parser │ ───────────────► │ Renderer │ ──► HTML string
└─────────┘ └────────┘ └──────────┘
One-pass Builds AST Stateless
O(n) Block + Inline traversal
| Class | Responsibility |
|---|---|
Lexer |
Tokenises the input line by line into Token[] (tabs expanded to 4-column stops, CommonMark §2.1) |
InlineParser |
Recursive delimiter-stack scanner for inline elements |
Parser |
Consumes Token[] and builds a DocumentNode AST |
HtmlRenderer |
Traverses the AST and emits escaped HTML; void elements are self-closed (<br />, <hr />, <img … />) as in the CommonMark spec |
HtmlSanitizer |
DOM-based sanitizer used when allowRawHtml: true |
FrontMatterParser |
Extracts and parses the YAML-subset front matter block |
IcuNormalizer |
NFC-normalizes input via PHP intl if available |
MarkdownParser |
Public façade — wires all stages together |
All AST nodes are immutable (readonly properties, PHP 8.2+).
Security
All user-supplied content is escaped via htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before being written to output. This covers:
- Text content (all
TextNodevalues) - Code content (
CodeNode,FencedCodeNode) - HTML attributes (
href,src,alt,title,class)
Dangerous URL schemes (javascript:, vbscript:, data:) in links are detected at parse time and rendered as literal text rather than <a> elements.
Raw HTML is escaped by default. Passing allowRawHtml: true enables DOM-based sanitization via HtmlSanitizer:
- Forbidden tags (
script,iframe,form, etc.) are removed with their entire subtree. - Unknown tags are unwrapped (children promoted).
- All
on*event handlers,style, andjavascript:/data:URL attributes are stripped. target="_blank"links getrel="noopener"injected automatically.
Resource limits: input is limited to maxBytes (1 MiB by default). Short table rows are completed with empty cells up to 100,000 cells per document; past that, they stay short. Parse time stays roughly linear in input size on pathological input (tests/Security). Memory use is high at the limit: a 1 MiB document needed from about 100 MB to 800 MB in stress tests, depending on its content, so use a lower maxBytes with PHP's default memory_limit of 128 MB.
Testing
# PHPUnit (requires dev dependencies) composer install php vendor/bin/phpunit --no-coverage # Standalone (no PHPUnit needed) php test.php
Benchmark
benchmark/ compares this library with league/commonmark, Parsedown and michelf/php-markdown on the same generated document (headings, emphasis, links, lists, fenced code, blockquote, table) in three sizes. It installs the library from the parent directory as a Composer path repository, so it measures your working copy.
cd benchmark composer install # copies the parent directory into vendor/ — re-run after changing src/ php benchmark-markdown.php
Sample results (PHP 8.2.29, Windows x64, no OPcache/JIT, best time per parser over 6 runs), lower is better:
| Document | php-markdown/parser | Parsedown | michelf/php-markdown | league/commonmark |
|---|---|---|---|---|
| small (1 KB, 1,000 runs) | 0.57 ms | 0.38 ms | 0.45 ms | 2.01 ms |
| medium (10 KB, 200 runs) | 4.30 ms | 4.75 ms | 4.72 ms | 14.47 ms |
| large (82 KB, 20 runs) | 35.73 ms | 32.61 ms | 37.05 ms | 117.58 ms |
Timings vary by machine and between runs. On the small document, the order was the same in every run. On the medium and large documents, the three fastest parsers swapped places from run to run, so their differences there are within the noise. The memory column printed by the script is the memory_get_usage() delta across the loop, not peak usage, so it is not reported here.
Limitations
The following are not supported:
- LaTeX / math (
$…$,$$…$$) - Definition lists
- Custom HTML attributes in Markdown syntax
License
MIT © 2026