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

Prosemirror To Html Laravel Package

ueberdosis/prosemirror-to-html

Convert ProseMirror and TipTap JSON documents into clean HTML with this Laravel/PHP package. Render rich text content from your editor reliably, handling nodes, marks, and nested structures through a simple, straightforward API.

Deep Wiki
Context7

Getting Started

Install and let the package's auto-discovered service provider register the ProseMirrorToHtml facade:

composer require ueberdosis/prosemirror-to-html

There is no meaningful config file to publish — the package is one small renderer plus a registry of node/mark classes. The entire surface is:

use ProseMirrorToHtml\Facades\ProseMirrorToHtml;

$html = ProseMirrorToHtml::render($document); // array or JSON string

$document is the raw TipTap/ProseMirror document ({"type":"doc","content":[...]}). The top-level doc node is not looked up in the registry; rendering starts at $document->content, so you don't need to register doc yourself.

Read these four files before anything else — they are the documentation:

  • src/Renderer.php — render(), renderNodes(), renderNode(), and the addNode(s) / addMark(s) registry.
  • src/Nodes/Node.php and src/Marks/Mark.php — the two abstract base classes you extend.
  • src/Nodes/* and src/Marks/* — the default set (paragraph, heading, lists, blockquote, codeBlock, hardBreak, hr, image, text; bold, italic, link, code, underline, strikethrough).

First use case: a TipTap-backed article body. $post->content holds the editor JSON; Blade prints {!! ProseMirrorToHtml::render($post->content) !!}. That works out of the box for a vanilla editor. Everything after that is about the gap between your editor schema and the package's ~15 default renderers.

Implementation Patterns

Model accessor + cache. The renderer JSON-decodes on every call and does no caching, so wrap it:

public function getHtmlAttribute(): string
{
    return Cache::rememberForever("post:{$this->id}:html", fn () =>
        ProseMirrorToHtml::render($this->content)
    );
}

Cast the column as array (not json). json gives you a stdClass graph, which the renderer rejects; array round-trips fine and keeps Blade/Blade directives honest.

Custom nodes for every TipTap extension you enable. This is the main day-to-day work. Subclass and register once in a service provider:

namespace App\ProseMirror\Nodes;

use ProseMirrorToHtml\Nodes\Node;

class Mention extends Node
{
    protected $tag = 'span';

    public function getAttrs($node)
    {
        return ['class' => 'mention', 'data-id' => $node->attrs->id];
    }

    public function getContent($node)
    {
        return '@' . htmlspecialchars($node->attrs->label ?? '');
    }
}
// AppServiceProvider::boot()
ProseMirrorToHtml::addNodes(['mention' => \App\ProseMirror\Nodes\Mention::class]);
ProseMirrorToHtml::addMarks(['highlight' => \App\ProseMirror\Marks\Highlight::class]);

Marks are simpler — a highlight mark is usually just protected $tag = 'mark'; plus getAttrs() for color.

Keep the registry in lockstep with the editor schema. Write one test that walks every fixture document from your editor and asserts render() doesn't throw. Add a fixture each time you enable an extension. This is the single highest-value test you can write for this package.

Post-process for presentation concerns. The renderer emits bare tags with no classes and no inline styles. For email or design-system HTML, render then rewrite with DOMDocument:

$dom = new \DOMDocument();
$dom->loadHTML('<div>'.$html.'</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
// add classes/styles, then $dom->saveHTML()

Do the same for footnotes, anchor IDs on headings, and lazy-loading images.

Sanitize at the boundary. The output is user-generated HTML. If untrusted authors can write JSON, run the result through HTMLPurifier (or an allowlist strip_tags) before echoing. The package escapes text nodes but says nothing about your custom getContent()/getAttrs() return values.

Plain text and excerpts. strip_tags(ProseMirrorToHtml::render($doc)) + Str::limit() is the fastest newsletter/preview path; for anything richer, map to Markdown instead of trying to make the HTML email-safe.

Gotchas and Tips

  • Archived since August 2021. No security patches, no new TipTap node types, no PHP 8.x guarantees. Pin the exact version and treat it as vendored code you own. If your editor uses tables, task lists, or media embeds added after 2021, budget for forks or a hand-rolled renderer — the JSON walk is genuinely ~200 lines, so "replace it" is a legitimate option.
  • Unknown node or mark types throw. Enabling a TipTap extension without registering a renderer turns into a 500 on the first document that uses it. Wrap render() in a try/catch that logs $node->type and falls back to a safe plaintext render, at least in production.
  • stdClass vs array. Inside getAttrs()/getContent() you get objects: $node->attrs->level, not $node->attrs['level']. Check isset($node->attrs->x) — optional attrs are frequently absent and a missing property is an error, not null.
  • Attributes are silently dropped. The base Node::getAttrs() returns [], so TipTap's textAlign, class, data-*, and language on code blocks may vanish without warning. If the rendered output "looks almost right," this is why.
  • The link mark defaults to target="_blank" with rel="noopener noreferrer nofollow". Wrong for internal links and awkward for mailto:. Override getAttrs() on a custom Link mark and register it under the link key.
  • The facade resolves a singleton with mutable state. addNode() called inside a request loop mutates shared state for the whole process. Register only in a service provider; never inside a controller or job.
  • Pass the whole document, not a fragment. renderNode() exists for single nodes but expects the marks context the renderer normally threads down; hand-rendering a text node without it quietly loses formatting.
  • Output is compact — no indentation or newlines between tags. Snapshot tests should assert against exact strings, and diff failures are noisy; assert on DOM structure or normalize whitespace first.
  • Empty/null content. render(null) and render('') are not forgiving. Guard with blank($this->content) ? '' : ... in the accessor.
  • Debugging tip: when output is wrong but not erroring, dd(ProseMirrorToHtml::renderNodes($doc->content)) on a minimal fixture and bisect the JSON — a two-node document isolates a bad renderer far faster than reading the class.
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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource