ueberdosis/tiptap-php
Convert Tiptap content between HTML and JSON in PHP. Set content, get documents, JSON, or HTML, sanitize and modify content, and extend it with custom nodes and marks. A PHP port of the Tiptap editor API.
composer require ueberdosis/tiptap-php
There's no service provider and no config file — it's a framework-agnostic package you instantiate directly. In Laravel, the only "setup" most apps need is a thin wrapper so you don't sprinkle new \Tiptap\Editor around the codebase:
// app/Services/Tiptap.php
namespace App\Services;
class Tiptap
{
public function editor(array $extensions = []): \Tiptap\Editor
{
return new \Tiptap\Editor([
'extensions' => $extensions ?: [new \Tiptap\Extensions\StarterKit],
]);
}
public function toHtml(array|string $content): string
{
return $this->editor()->setContent($content)->getHTML();
}
}
Where to look first
README.md for the API surface (setContent, getHTML, getJSON, getDocument, getText, sanitize, descendants).src/Editor.php — the four entry points and how input format is detected.src/Extensions/StarterKit.php — the default extension list; this tells you exactly which nodes/marks you get for free.src/Nodes/ and src/Marks/ — the best documentation of the extension API is the extensions themselves.tests/ — copy fixtures from here when writing your own round-trip tests.First use case: render stored Tiptap JSON as HTML
Your frontend editor posts Tiptap JSON. Store it, then render it server-side on public pages and in emails:
$html = (new \Tiptap\Editor)
->setContent($post->content) // array or JSON string
->getHTML();
<div class="prose">{!! $html !!}</div>
Equally useful first win: sanitize on the way in so the JSON you persist can only ever contain nodes/marks your schema knows about.
$clean = (new \Tiptap\Editor)->sanitize($request->input('content'));
Store JSON, render HTML (or cache the HTML). Treat Tiptap JSON as the canonical format — it's what the frontend needs to reopen the document. Render HTML on demand, and cache it keyed by a hash of the content plus a version of your extension config:
public function getHtmlAttribute(): string
{
return Cache::remember(
'tiptap:html:'.md5($this->content.json_encode(config('tiptap.version'))),
now()->addDay(),
fn () => app(Tiptap::class)->toHtml($this->content),
);
}
Bump tiptap.version whenever you change extensions or HTMLAttributes, then re-render in a queued job if you need a backfill.
Sanitize on write, render on read. sanitize() is schema-based cleaning, not a generic HTML purifier — it parses into the Tiptap document model and re-renders, which drops <script>, unknown tags and disallowed nesting. Run it in a FormRequest or model observer so dirty content never lands in the database:
protected function prepareForValidation(): void
{
$this->merge([
'content' => (new \Tiptap\Editor)->sanitize($this->input('content')),
]);
}
Generate a plain-text column for search. Cheap and effective for Scout/Meilisearch/LIKE queries:
$post->search_text = (new \Tiptap\Editor)
->setContent($post->content)
->getText(['blockSeparator' => "\n"]);
Normalize content on save with descendants(). Great for enforcing app rules: cap heading levels, add rel="noopener" to links, inject heading ids for anchors, strip empty paragraphs, or convert a node produced by a newer frontend into something the backend understands.
$editor->setContent($content);
$editor->descendants(function (&$node) {
if ($node->type === 'heading') {
$node->attrs->level = min($node->attrs->level ?? 1, 3);
}
if ($node->type === 'text' && isset($node->marks)) {
// tweak marks here
}
});
$content = $editor->getDocument();
Configure HTML output for your CSS. Because the HTML is rendered server-side, put your Tailwind/CSS classes in the extension config rather than relying on the frontend editor's classes:
new \Tiptap\Extensions\StarterKit([
'heading' => [
'levels' => [1, 2, 3],
'HTMLAttributes' => ['class' => 'font-bold mt-8 mb-4'],
],
'link' => [
'HTMLAttributes' => ['class' => 'text-blue-600 underline', 'rel' => 'noopener nofollow'],
],
])
Use different editors per field. A comment box doesn't need images, headings or code blocks; a docs page does. Keep a config/tiptap.php with named profiles (minimal, full, blog) and resolve them in your Tiptap wrapper. Mirror the same profile in the JS editor config so front/back stay in sync.
Custom nodes for app-specific blocks. If your JS side has a custom node (callout, embed, YouTube, tenant-specific widget), implement the matching PHP Node/Mark with the same $name, parseHTML() and renderHTML(). Without it, that JSON may be dropped or mangled on render. Look at src/Nodes/ for the pattern; renderHTML returns a [tag, attributes, 0] tuple where 0 is the content hole.
class Callout extends \Tiptap\Core\Node
{
public static $name = 'callout';
public function addOptions()
{
return ['HTMLAttributes' => ['class' => 'callout']];
}
public function parseHTML()
{
return [['tag' => 'div[data-callout]']];
}
public function renderHTML($node)
{
return ['div', ['data-callout' => $node->attrs->type ?? 'info', 'class' => 'callout'], 0];
}
}
Code blocks. Prefer CodeBlockHighlight (pure PHP via highlight.php) in a typical Laravel deploy — no Node process on the server. Reach for CodeBlockShiki only if you already have Node + shiki installed, since it shells out.
'extensions' => [
new \Tiptap\Extensions\StarterKit(['codeBlock' => false]),
new \Tiptap\Nodes\CodeBlockHighlight,
],
Test the round trip, not just the output. Keep fixtures of real frontend JSON and assert both directions in Pest/PHPUnit:
it('round-trips a blog post', function () {
$editor = new \Tiptap\Editor;
$doc = json_decode(file_get_contents(base_path('tests/fixtures/post.json')), true);
$html = $editor->setContent($doc)->getHTML();
$again = $editor->setContent($html)->getDocument();
expect($again)->toEqual($doc);
});
This is the single most valuable test to have, because it catches the "frontend added a node the backend forgot" class of bugs before users do.
API responses. For a headless setup, return both shapes from a resource so SPA clients can pick:
return [
'json' => $post->content,
'html' => $post->html, // cached accessor
'text' => $post->search_text,
];
descendants() needs &$node. Without the ampersand you're mutating a copy and nothing changes — the silent no-op is the single most common bug with this package.getJSON() returns a JSON string, getDocument() returns an array, sanitize() returns whatever format you fed in (string in, string out). Don't assume you always get an array back.<p> in, <h1> out) and it admits some schema restrictions aren't enforced (e.g. marks inside CodeBlock). Treat the tests as the source of truth, and expect HTML → JSON → HTML to normalize your markup rather than preserve it byte-for-byte.<script> and unknown tags, but it won't stop a javascript: URL in a custom link/embed extension. Validate URLs yourself. If you accept arbitrary user HTML, run it through a real purifier first, then sanitize().['codeBlock' => false] on StarterKit then adding CodeBlockHighlight after it — if the order is reversed the default CodeBlock still wins.100; higher priority is evaluated first. If your custom node shares markup with a built-in one (the TaskItem vs ListItem case), set $static $priority explicitly, otherwise matches go to whichever extension the registry hits first and you'll chase "why is my custom node rendering as a paragraph".href crashed in 2.1.x. Pin ^2.2 and validate attribute types in custom extensions — arrays/objects in HTMLAttributes were a source of TypeErrors before the attribute-rendering hardening.StarterKit loses Text. If you hand-pick nodes for a minimal editor, remember Document, Paragraph and Text are required or text silently disappears.getText() block spacing. The default separator between blocks is a blank line; pass blockSeparator to control it, and be aware list items and hard breaks have their own handling — check output on nested lists before feeding it to a search index.null from a new record can throw during parsing. Wrap conversions in a try/catch and fall back to strip_tags($content) so a corrupt row doesn't 500 your index page.{!! !!} output. The temptation with HTML from getHTML() is to echo it raw. Always store content that has already passed sanitize(), and be aware stored legacy content may not have.descendants() on very large documents is memory-hungry.HTMLAttributes mirrored between the JS Editor and your PHP profile. When the frontend bumps a Tiptap major, run your round-trip fixtures — this package trails the JS releases and a new JS node type will not exist on the PHP side until someone adds it.How can I help you explore Laravel packages today?