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

Markdownify Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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

  • Input side: HTML is untrusted. If you convert before rendering, sanitize the raw HTML — especially attribute values, since v2.3.3 now accepts unquoted attributes.
  • Output side: the Markdown you store becomes HTML you render. Markdown is not a sanitizer; HTML embedded in it (or attributes that survive the converter) will reach your users if you skip this.

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:

  • List followed immediately by a paragraph (spacing fix, #46).
  • Headerless <table> → Markdown table with empty header row (#44).
  • Multi-line inline <code> → fenced block (#34).
  • Word <o:p> inside <p> — tag stripped, inner text preserved.
  • Unquoted attribute HTML (<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:

  1. Sanitize incoming HTML (allowlist attributes/tags).
  2. Convert to Markdown.
  3. Store raw + sanitized HTML + Markdown.
  4. Render: Markdown → HTML → render with the same sanitizer applied to the rendered HTML.

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.

Gotchas and Tips

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:

  • Turn it on in one place (the container binding).
  • Snapshot/visual-regression tests will diff when you flip it.
  • Design/QA should sign off; it’s a rendered-HTML change, not a dependency bump.

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.

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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib