pixel418/markdownify
Markdownify is a fast, extensible PHP library that converts HTML into clean Markdown. It handles common HTML elements, supports custom converters, and integrates easily into Laravel or any PHP project for generating readable Markdown from web content.
Install
composer require pixel418/markdownify
Zero transitive deps, so the install is a single line in composer.json.
Pick your converter class first
Markdownify\Converter — baseline Markdown output. Use it if input is trusted, simple HTML (paragraphs, headings, links, lists).Markdownify\ConverterExtra — Markdown Extra semantics: tables, footnotes, definition lists, fenced code, attribute syntax. This is almost always what you actually want for real content — CMS exports, Word paste, imported legacy HTML.Start with ConverterExtra unless you specifically need to avoid tables/footnotes in output (e.g. feeding a strictly CommonMark renderer downstream).
First use case — the one that pays for itself
Convert user-submitted HTML (paste-from-Word, WYSIWYG output, scraped content) into Markdown you can store and re-render:
use Markdownify\ConverterExtra;
$converter = new ConverterExtra();
$markdown = $converter->parseString($html);
Bind it once so it’s not scattered across the codebase:
// AppServiceProvider::register()
use Markdownify\ConverterExtra;
$this->app->singleton(ConverterExtra::class, function () {
$converter = new ConverterExtra();
$converter->setAddCssClass(false); // keep output styling-free
return $converter;
});
Then inject ConverterExtra into a thin service (HtmlToMarkdownService) and never call the converter directly from a controller or Blade view.
Where to look first — the README (usage + examples), the Converter class methods (__construct, parseString), and ConverterExtra for the extras layer. The changelog for v2.3.3 is also worth reading because it documents the tolerance behaviors (Word o:p, headerless tables, unquoted attributes) you’ll be relying on.
Wrap it, don’t sprinkle it
One application service, injected via the container. Reasons: you can enforce sanitization in one place, swap the CSS-class flag for all callers at once, and unit-test conversion separately from controllers.
final class HtmlToMarkdown
{
public function __construct(private ConverterExtra $converter) {}
public function convert(string $html): string
{
return $this->converter->parseString($html);
}
}
Choose Converter vs ConverterExtra per call site, not per app
If some content pipelines must not emit Markdown Extra syntax (e.g. you render through a strict CommonMark engine elsewhere), keep a Converter binding for those paths. Don’t force one class on the whole codebase.
Sanitization belongs on both ends of the pipeline
For Laravel, run both through your own policy (e.g. mews/purifier, or an explicit HTMLPurifier allowlist) rather than trusting the converter.
Bulk / import workflows — queue it
Conversion is CPU-bound and synchronous. Batch imports (wiki migration, ticket archives) should run through a queued job:
ConvertHtmlToMarkdown::dispatch($recordIds);
Cache the output if the source HTML never changes.
Preserve the source HTML
Since the Markdown conversion is lossy (whitespace, certain attributes, some inline formatting), keep the original HTML column. Re-converting later shouldn’t require re-fetching from the source, and if a conversion bug bites you, you can reprocess offline.
Regression fixtures for the v2.3.3 fixes
Pin these as feature tests so a future upgrade can’t silently change them:
<table> → Markdown table with empty header row (#44).<code> → fenced block (#34).<o:p> inside <p> — tag stripped, inner text preserved.<a href=/foo>) parses.setAddCssClass(false) — no class="..." in emitted attribute blocks.Custom converters
Subclass Converter (or ConverterExtra) and override per-tag handling when you need a behavior the library doesn’t ship (e.g. mapping a custom CMS tag to a blockquote, or stripping <figure> wrappers). Register your subclass in the container alongside the default so you can swap later.
Pipeline ordering (important)
The reliable order for user-generated content is:
Do not reorder 1 and 2 — feeding unsanitized HTML into any converter gives the parser more surface area to walk, and v2.3.3 explicitly widened what it accepts.
ConverterExtra emits CSS classes by default
The library adds Markdown attribute blocks (e.g. {#id .class}) for some elements. If your frontend owns styling (Tailwind, design system, headless API), call setAddCssClass(false) — but treat it as a cross-cutting decision:
Sanitization is not included
Nothing in this package removes scripts, event handlers, or javascript: URLs from embedded HTML. If you convert user content, you must sanitize downstream — and again after Markdown-to-HTML rendering. This is the single biggest production risk with the package.
Not a spec-compliant CommonMark converter
It is tolerant and pragmatic, not formally spec-tested against CommonMark/GFM. If you need byte-for-byte spec parity with a client-side renderer, this is the wrong tool. The tolerance is the feature; don’t assume it won’t occasionally diverge.
Output changes between patch versions
v2.3.3 changed list spacing, table output (headerless tables now render), and multiline code blocks. Any cached rendered fragment or email template that previously relied on the old shape will diff. Keep fixture tests and clear the cache in staging.
Word o:p handling is a behavior, not a guarantee
The tag is stripped and contents preserved — that’s usually right, but confirm against your content retention requirements. If your policy is “don’t lose anything from Word paste,” write a fixture and validate it every upgrade.
Unquoted attributes are accepted input now
Convenient for legacy HTML, but it means dirty markup with href=/x or src="x (malformed) may parse in ways you don’t expect. Sanitize before conversion so the parser never sees attributes you wouldn’t allow anyway.
No configuration object
ConverterExtra is controlled through the constructor and specific setters (setAddCssClass), not a rich options array. If you need broad behavioral control, plan to subclass. Don’t build a config/markdownify.php with a dozen fictional keys — expose only what the API actually provides.
parseString is stateless on the converter
It’s safe to reuse the singleton across calls. Don’t instantiate new ConverterExtra() per conversion in a loop — pointless allocation, and it defeats the container binding.
PHP version constraints matter
Check composer.json in the package before merging — the constraint may not match your oldest supported PHP. If you run multiple services at different PHP majors, verify each.
Debugging output that “looks wrong”
The converter has no debug logging. Dump $converter->parseString($html) in a tinker session on the smallest HTML fragment that reproduces the problem — usually one table cell or one <code> block. Most “it broke” reports turn out to be one of: table with no header, <pre><code> with a language class on the wrong node, or a nested list with a following paragraph.
Extension point worth knowing
Subclassing and overriding tag handlers is the intended escape hatch, not a supported configuration surface — treat it as coupling to internals. If the upstream ships a richer options API later (the CSS-class setter is the first hint), migrate to that rather than maintaining forks of the subclass.
How can I help you explore Laravel packages today?