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.
Architecture fit
league/html-to-markdown is a framework-agnostic, single-responsibility library: construct HtmlConverter, hand it an HTML string, get Markdown back. There is no ServiceProvider, no facade, no config publishing, no Laravel integration surface at all. That is a feature, not a defect — it means zero coupling to our application lifecycle and no upgrade treadmill tied to Laravel major versions. The trade-off is that the TPM should budget for a thin first-party adapter rather than assuming "composer require and go."
It fits cleanly anywhere we already hold a string of trusted-or-sanitized HTML: a queued job, a console command, an artisan import, a Nova/Filament action, an API transformer. It does not fit streaming or chunked conversion well — the implementation builds a full DOMDocument from the input, walks the tree, and converts from the deepest nodes outward. Peak memory is therefore a function of the largest single HTML document we will ever process, not the average. For typical CMS article bodies and email templates this is immaterial; for full-page scrapes of 5–20 MB HTML it is a sizing question we must answer before committing.
The extension model is the strongest architectural asset. HtmlConverter::getEnvironment() and the now-public Environment::addConverter() let us register custom ConverterInterface implementations for our own house HTML patterns (custom callouts, <figure>/<figcaption>, embedded media) and opt into TableConverter explicitly where GFM tables are wanted. Because Environment was made final in 4.0.0 and method visibility was tightened in the same release, the extension contract is now stable — but it also means we cannot subclass Environment for clever wiring. Composition only.
Integration feasibility
High. Installation is composer require league/html-to-markdown, requiring PHP 7.2+ and the xml, libxml, and dom extensions. Those extensions ship by default on most distributions but are commonly disabled on CentOS/RHEL-style images and in some hardened container bases — a five-minute check in our Dockerfile and CI image, but a check that has historically cost teams a half-day when skipped. Our Laravel version is irrelevant here; the only real compatibility axes are PHP runtime version and extension availability.
Integration effort for a competent Laravel engineer is genuinely small — a singleton binding, a config array, a value-object DTO for options, and a service class wrapping convert(). The substantive work is not wiring; it is policy definition: deciding which of the ~10 documented options we standardize on and enforcing that decision centrally rather than letting each call site pass its own array.
Technical risk
The dominant risk is security-adjacent and is called out explicitly in the README's own warning: by default the library preserves HTML tags without Markdown equivalents — <span>, <div>, <iframe>, <script> and friends pass through into the output. If any conversion path accepts end-user HTML, an unconfigured converter is a stored-XSS vector wherever that Markdown is later rendered with HTML enabled. Mitigation is layered and non-negotiable: set strip_tags and/or remove_nodes on our default converter instance, and run untrusted input through HTML Purifier before conversion. The library is a converter, not a sanitizer; the README says so, and we should encode that as an ADR so it survives team turnover.
Secondary risks, ranked:
hard_break, header_style, and table support must be aligned deliberately with whichever renderer consumes the result.thephpleague org, led by @colinodell — a well-known, long-tenured maintainer in the PHP ecosystem. Historically healthy, with a dense changelog and semantic versioning discipline (a clean 4.x → 5.x progression). Single-maintainer concentration is the residual concern, mitigated by the license: the code is forkable and small.Dependents: 0 alongside ~1.9k stars and a README badge for total downloads — clearly a measurement artifact, but it means we must not treat "dependents" as a proxy for production adoption. Similarly, the listed last-release date (2026-10-19) is future-dated and internally inconsistent with the [unreleased]: ...compare/5.1.1 changelog link, implying the feed is unreliable rather than that there is genuinely a future release. Verify the actual Packagist version and commit history during the spike; do not plan against a scraped date.Key questions
strip_tags/remove_nodes becomes a launch gate, not a follow-up ticket.Stack fit
Excellent, with one caveat that drives the whole plan: it is not a Laravel package, so there is nothing to php artisan vendor:publish. We build the integration ourselves, and we should build it once — a MarkdownConversionService in our application layer, bound as a singleton, exposing an intent-revealing API (e.g. convertArticleBody(), convertEmailToText()) rather than leaking HtmlConverter into controllers. Consumers should never new HtmlConverter() directly; that is how option drift starts.
Configuration belongs in config/markdown.php keyed by profile — default, email, llm, strict — each mapping to a documented option set. Profiles make the security posture auditable in one file and let us tighten strip_tags globally without a scattered refactor.
Migration path
Greenfield, so no data migration. The migration work is organizational: replacing any ad-hoc regex-stripping or strip_tags() calls currently used for HTML-to-text with the new service. Inventory those first — regex-based HTML stripping is a common latent bug source and a good "found value" item for the rollout narrative.
If a fidelity blocker appears (nested lists being the likely candidate), the fallback path is a custom ConverterInterface for the offending node type registered on the Environment, which is a contained, incremental fix rather than a fork. That escape hatch is why the extension model matters more than the default output quality.
Compatibility
Confirm PHP runtime ≥ 7.2 (almost certainly satisfied) and verify dom, xml, libxml in the base image and in CI. Pin the major version constraint in composer.json and let Renovate/Dependabot surface minor bumps. Watch for the 4.x → 5.x boundary if we are starting on 4.x: 4.0.0 was the release that stabilized visibility and finalized Environment, so treat 4.x as the practical floor. Align the bold_style/italic_style/hard_break/header_style choices with our renderer at configuration time, not at debug time.
Sequencing
strip_tags/remove_nodes at, and where the output is rendered. Sign-off from whoever owns application security.Total realistic elapsed time for a production-ready integration: 2–3 weeks for one engineer, most of it in steps 1 and 4.
Maintenance
Low steady-state. Small, focused, semantically versioned dependency with an MIT license and a documented changelog — upgrading is usually a composer update and a green test run. The recurring cost is not the library; it is our adapter and golden-file suite, which becomes shared platform surface that needs an owner. Budget a few hours per quarter for dependency review plus the occasional fidelity regression triage. Because Environment is final and visibility was tightened in 4.0.0, we should not build clever subclass-based extensions — keep customization to ConverterInterface implementations, which are the sanctioned path.
Support
Developer-facing library, no end-user support surface. The realistic support burden is internal: "why did this document convert differently than expected?" The answer should always be a profile lookup or a fixture test, not an investigation — which is exactly what the config profiles and snapshot suite buy us. Document the fidelity limitations (nested lists, no Extra/MultiMarkdown, inline-only links and images) in the adapter's README so the answer exists before the question is asked.
Scaling
CPU-bound string processing over a DOM tree. Throughput scales horizontally with workers; there is no shared state and no I/O, so queue-based conversion scales near-linearly. Two caveats:
DOMDocument of a large HTML page can run several times the source size in memory. Set an input size ceiling in the adapter (reject or truncate above, say, 2 MB for synchronous paths) rather than discovering the ceiling via OOM-killed workers.Failure modes
DOMDocument is lenient, but deeply broken input can produce surprising trees rather than a clean exception. Wrap convert() and log input hashes on unexpected output; never let a conversion failure block a user's save. Degrade to "keep the HTML" rather than losing content.Class 'DOMDocument' not found. Add an extension assertion to CI and to container health checks.Ramp-up
Effectively zero for a PHP developer. The API is one method; the README documents every option with a runnable example, and the changelog is unusually clear about behavioral changes across majors. The learning is not "how do I call this" — it is "which options do we standardize on and why," and that knowledge lives in our ADR and config, not in the upstream docs. Plan a short onboarding note for the adapter and treat the upstream README as the reference for everything below it.
Overall: a low-risk, well-scoped, appropriately boring dependency. The library itself is not where the risk lives; the risk lives in (a) the default tag-preservation behavior and (b) nested-list fidelity. Both are addressable in the first week, both are cheap to verify, and neither is a reason to look for an alternative. The opportunity score of 5.56 reads correctly: this is a solved problem with an established solution, so the opportunity is not in the package but in standardizing and sanitizing how we use it.
How can I help you explore Laravel packages today?