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.
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.
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.
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.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.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.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.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.render(null) and render('') are not forgiving. Guard with blank($this->content) ? '' : ... in the accessor.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.How can I help you explore Laravel packages today?