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

Html To Markdown Laravel Package

nickcernis/html-to-markdown

Converts HTML to Markdown for PHP 7.2+, letting you edit existing HTML content as Markdown, store HTML while editing in Markdown, or turn HTML email into plain text. Install via Composer.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the real package. The name in the brief (nickcernis/html-to-markdown) is the legacy package. The maintained one is:

composer require league/html-to-markdown

Namespace is League\HTMLToMarkdown. This mismatch bites people who copy old blog posts.

Check the extension, not just composer. The library needs ext-dom, ext-libxml, and ext-xml. On a stock Laravel Forge/Herd box these are present; on minimal Docker images (php:8.x-cli-alpine with a hand-picked extension list) they often are not. Symptom is Class "DOMDocument" not found at runtime, not at install time. Add to composer.json:

"require": { "ext-dom": "*", "ext-libxml": "*" }

Register it once. Don't new HtmlConverter() inside controllers — the converter holds a mutable Config and Environment, and you want the options in one place:

// app/Providers/AppServiceProvider.php
use League\HTMLToMarkdown\HtmlConverter;
use League\HTMLToMarkdown\Converter\TableConverter;

public function register(): void
{
    $this->app->singleton(HtmlConverter::class, function () {
        $converter = new HtmlConverter(config('markdown.conversion', []));
        $converter->getEnvironment()->addConverter(new TableConverter());

        return $converter;
    });
}
// config/markdown.php
return [
    'conversion' => [
        'strip_tags'             => true,
        'remove_nodes'           => 'script style iframe noscript',
        'header_style'           => 'atx',
        'hard_break'             => true,
        'strip_placeholder_links'=> true,
    ],
];

First use case worth shipping: your app stores rich text (<p>, <h3>, <ul>) from Trix/CKEditor/Quill in a body_html column. You need a plain-text/Markdown export — for a "Download as Markdown" button, a docs export, a GitHub README sync, or an LLM prompt payload where Markdown tokenizes better than HTML.

public function export(Post $post, HtmlConverter $converter): string
{
    return trim($converter->convert($post->body_html));
}

Then look at, in order: HtmlConverter::getConfig() (what's tunable), HtmlConverter::getEnvironment() (what's pluggable), and the src/Converter/ directory (what's already handled). Those three tell you everything the README doesn't.

Implementation Patterns

Sanitize → convert → normalize

Treat conversion as one stage in a pipeline, never the whole thing:

$clean    = Purifier::clean($post->body_html, 'markdown_export'); // mews/purifier
$markdown = $converter->convert($clean);
$markdown = preg_replace("/\n{3,}/", "\n\n", trim($markdown));   // collapse DOM whitespace artifacts

The converter is not a sanitizer. Route untrusted HTML through HTML Purifier first, then rely on strip_tags / remove_nodes as a second pass.

Dedicated converter service for a document type

Wrap the library so options and post-processing live with the document, not the caller:

namespace App\Support;

class MarkdownExporter
{
    public function __construct(private HtmlConverter $converter) {}

    public function forGithub(string $html): string
    {
        $c = clone $this->converter;              // isolate config changes
        $c->getConfig()->setOption('header_style', 'atx');
        $c->getConfig()->setOption('use_autolinks', true);
        $c->getEnvironment()->addConverter(new \League\HTMLToMarkdown\Converter\TableConverter());

        return trim($c->convert($html));
    }
}

clone matters — see Gotchas.

Custom converters for domain markup

Anything your editor emits that Markdown can't express needs a converter, otherwise it survives as raw HTML. Extend AbstractConverter and register with a priority so you can win against the built-in:

use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;

class CalloutConverter implements ConverterInterface
{
    public function convert(ElementInterface $element): string
    {
        $text = trim($element->getValue() ?? '');
        return $text === '' ? '' : "\n> **{$element->getAttribute('data-type')}**\n>\n> " . str_replace("\n", "\n> ", $text) . "\n\n";
    }

    public function getPriority(): int { return 10; }          // higher wins
    public function supports(ElementInterface $element): bool
    {
        return $element->getTagName() === 'div'
            && $element->getAttribute('class') === 'callout';
    }
}
$converter->getEnvironment()->addConverter(new CalloutConverter());

AbstractConverter also gives you getChildrenAsText() / getChildNodes() helpers so you don't have to walk the tree by hand — worth reading in src/Converter/AbstractConverter.php before writing a custom one.

Queued bulk conversion

Conversion is CPU-bound DOM work. For a backfill over thousands of rows, never loop in a request:

class ConvertPostToMarkdown implements ShouldQueue
{
    public function __construct(public int $postId) {}

    public function handle(HtmlConverter $converter): void
    {
        $post = Post::findOrFail($this->postId);
        $post->update(['body_markdown' => trim($converter->convert($post->body_html))]);
    }
}

Dispatch in chunks (Post::chunkById(500, fn ($posts) => ...)) so a failed job is small and retryable. Consider idempotency: converting Markdown back to HTML and again is lossy, so gate on a markdown_version column rather than reconverting blindly.

Model-level accessor / cast

For the common "display this field as Markdown" case:

protected function bodyMarkdown(): Attribute
{
    return Attribute::get(fn () => trim(app(HtmlConverter::class)->convert($this->body_html)));
}

Cache the result in the column if the attribute is hit in a loop — an accessor re-parses HTML on every access.

Conversion in tests

Golden-file tests catch behavior drift better than assertions on individual strings, because whitespace is where this library changes:

public function test_docs_page_converts(): void
{
    $converter = app(HtmlConverter::class);
    $markdown = $converter->convert(file_get_contents(base_path('tests/fixtures/page.html')));

    $this->assertSame(
        file_get_contents(base_path('tests/fixtures/page.md')),
        $markdown
    );
}

Pin the library version in composer.json ("league/html-to-markdown": "^5.1") — minor releases have historically changed output formatting (e.g. escaping of */_ arrived in 4.0.1).

Gotchas and Tips

Unknown tags are preserved by default — this is the #1 surprise. <span>, <div>, <iframe>, <script>, and any custom element survive verbatim into your Markdown. For user content always set strip_tags => true and remove_nodes => 'script style iframe noscript'. Note the semantic difference: strip_tags removes the wrapper but keeps children; remove_nodes nukes the content too. Getting that backwards silently deletes paragraphs.

The config object is mutable and the converter is stateful. If you register a singleton and then call $converter->getConfig()->setOption(...) in one controller, every later consumer inherits it. Either build a fresh HtmlConverter per context or clone before mutating. This is a real source of "works locally, order-dependent in production" bugs.

Environment is final (since 4.0). You cannot subclass it to swap the converter registry wholesale. Use getEnvironment()->addConverter() — public since 4.0 — and control ordering via getPriority(). Custom converters that return early without checking supports() will swallow entire documents.

Header style default is setext. H1/H2 come out underlined (==== / ----) unless you set header_style => 'atx'. Most "why does my exported Markdown look weird" reports are this. H3+ is always ATX regardless.

br defaults to two trailing spaces, which many editors/linters strip. Set hard_break => true for GFM-clean output. Conversely, if you're round-tripping into a renderer that doesn't honor hard breaks, you'll get run-together lines.

The use_autolinks docs contradict themselves — the section says the easiest-syntax behavior is default, then labels false as default in the code comment. Don't trust it; check src/Config.php defaults or assert it in a test. Set it explicitly if output stability matters.

Tables are opt-in and won't be Markdown tables unless you add TableConverter. Without it you get passthrough HTML <table> — fine for some renderers, broken for others. Also remember tables aren't core Markdown; verify your target renderer (CommonMark + GFM extension does not enable tables by default).

Nested lists and lists inside blockquotes are explicitly unsupported (still on the to-do list). If your content model depends on deeply nested <ul>, budget for a custom converter or a post-processing pass. Same for footnote-style links — everything is inline by reference, no footnote collection.

Encoding is a classic failure. DOMDocument::loadHTML assumes ISO-8859-1 unless told otherwise, so UTF-8 content can produce mojibake. Either prefix a meta charset hint or pre-encode:

$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); // legacy but effective
$html = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $html;

The second approach is usually safer with modern libxml. Test with emoji, accented names, and CJK — not with ASCII fixtures.

Line endings are always \n regardless of platform (since 3.1.0). Don't build diffing logic around \r\n on Windows; and if you write to a Windows-targeted file, normalize on write.

Whitespace between inline elements is preserved but block boundaries get collapsed. You'll get \n\n\n runs and trailing spaces after lists. A normalize step (preg_replace("/\n{3,}/", "\n\n", ...) plus rtrim per line) belongs in your exporter, not in a bug report.

Empty input returns empty string (fixed in 3.1.1), so you can skip null checks — but null will still error. Cast or guard.

Performance: it builds a full DOMDocument and walks the tree; large documents (megabyte-scale) are slow and memory-hungry. There's no memoization. Cache converted output by content hash if the same HTML is rendered repeatedly, and keep conversion out of hot read paths.

Extension point checklist when output is wrong: (1) is the tag even reaching a converter — dump $element->getTagName(); (2) does your converter's getPriority() beat the built-in; (3) is a parent converter consuming children via getChildrenAsText() before yours runs. Conversion walks from the deepest nodes inward, so a too-eager parent converter (like a <div> handler) silently drops child markup.

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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
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