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

Tiptap Php Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. README.md for the API surface (setContent, getHTML, getJSON, getDocument, getText, sanitize, descendants).
  2. src/Editor.php — the four entry points and how input format is detected.
  3. src/Extensions/StarterKit.php — the default extension list; this tells you exactly which nodes/marks you get for free.
  4. src/Nodes/ and src/Marks/ — the best documentation of the extension API is the extensions themselves.
  5. 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'));

Implementation Patterns

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,
];

Gotchas and Tips

  • 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.
  • Return types differ by method. 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.
  • Not a full ProseMirror schema. The README's own examples are inconsistent (<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.
  • Sanitize is not HTMLPurifier. It drops what the schema doesn't allow, which handles <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().
  • Extension order matters when swapping nodes. ['codeBlock' => false] on StarterKit then adding CodeBlockHighlight after it — if the order is reversed the default CodeBlock still wins.
  • Priority decides parse conflicts. Everything defaults to 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".
  • Non-scalar attributes and non-string 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.
  • Custom extension list without 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.
  • Guard against empty or malformed content. An empty string or 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.
  • Don't render un-sanitized {!! !!} 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.
  • One editor instance per document, not per node. Construction parses the schema and instantiates every extension; in a loop over thousands of records (backfills, exports, sitemap generation) reuse the editor where possible and consider chunking, since descendants() on very large documents is memory-hungry.
  • Watch the changelog for attribute-merging changes. 2.2.0 hardened how attributes are merged during rendering — if you upgrade and CSS classes on links/headings suddenly double up or disappear, that's the release to diff against.
  • Frontend/backend parity is a discipline, not a feature. Keep extension names, options and 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.
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.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table