Skip to content

Rich editor

The rich editor lets the user write formatted text — headings, lists, links, tables, and more. It stores a structured TipTap document rather than raw HTML, which keeps the stored value safe to render. Create one with RichEditor::make().

RichEditor::make('article', 'Article')
->placeholder('Write your article…')

The field submits a JSON document. Before it reaches handle(), Lattice decodes it and strips every node and mark the field’s active extensions don’t allow — the client editor constrains what an honest user can produce, but the submitted JSON is client-controlled, so the server enforces the configured set the same way a Choice field validates its options. You receive a clean document array you can store as-is and render later by wrapping it in RichContent:

use Lattice\Form\RichContent;
$html = RichContent::make($document)->toHtml();

toHtml() validates the document against the editor’s schema (unknown nodes are stripped) and sanitizes the output, so it is safe to render directly. toText() returns a plain-text version.

->placeholder() sets the muted hint shown while the editor is empty.

Which editor features are active — and their toolbar order — is controlled from PHP. Every feature is an extension class under Lattice\Form\RichEditor\Extensions; a field without configuration ships the full default set. ->extensions() replaces it:

RichEditor::make('summary', 'Summary')
->extensions([
Bold::make(),
Italic::make(),
Heading::make()->levels(2, 3),
Link::make()->protocols('https', 'mailto'),
])

The array order defines the toolbar order. ->withExtensions() adds to the active set (or reconfigures an extension already in it, keeping its position), and ->withoutExtensions() subtracts by class or wire type:

RichEditor::make('body')
->withExtensions(Heading::make()->levels(1, 2)) // reconfigure the default heading
->withoutExtensions(Details::class, 'emoji'); // drop by class-string or wire type

To change the default set for every editor in the app, register a resolver in a service provider:

RichEditor::defaultExtensionsUsing(fn (): array => [
Bold::make(),
Italic::make(),
Link::make(),
]);
Extension Wire type Configuration
Bold bold
Italic italic
Strike strike
Underline underline
Highlight highlight
Code code
Heading heading ->levels(1, 2, 3) — allowed levels, 1–6 (default all six)
BulletList bullet-list
OrderedList ordered-list
Blockquote blockquote
CodeBlock code-block
HorizontalRule horizontal-rule
TextAlign text-align ->alignments('left', 'right') — subset of left/center/right/justify (default all)
Link link ->protocols('https', 'mailto') (default http/https/mailto), ->openOnClick() (default off)
Table table Insert defaults: ->rows(3), ->cols(3), ->withHeaderRow()
Details details
Emoji emoji ->emojis('🍕', '🌮') — the picker set (default 16 common emoji)

Configured extensions serialize their props onto the wire — Heading::make()->levels(2, 3) becomes {"type": "heading", "props": {"levels": [2, 3]}} — and the client reads them when it assembles the editor.

An extension is a pair: a PHP class that declares the wire type (and any typed configuration), and a client definition that maps that type to Tiptap behavior and toolbar items.

On the PHP side, extend EditorExtension and register the class in a service provider:

use Lattice\Form\RichEditor\Attributes\AsEditorExtension;
use Lattice\Form\RichEditor\EditorExtension;
use Lattice\Form\RichEditor\EditorExtensionRegistry;
#[AsEditorExtension('mention')]
class Mention extends EditorExtension
{
/**
* @var list<string>
*/
public array $triggers = ['@'];
public function triggers(string ...$triggers): static
{
$this->triggers = array_values($triggers);
return $this;
}
}
// AppServiceProvider::boot()
app(EditorExtensionRegistry::class)->register(Mention::class);

Public typed properties become the extension’s props on the wire, exactly like a component. Once registered, Mention::make()->triggers('@', '#') works in ->extensions(), and so does the plain string 'mention' (it instantiates the class with its defaults).

An extension that adds its own document nodes must also declare their schema type names via the protected $serverTypes property (e.g. ['mention']) — submitted nodes of types no active extension declares are stripped server-side. Toolbar-only extensions that insert plain text, like the built-in emoji picker, don’t need this.

On the client, add a definition for the same wire type to the app plugin before boot:

import type { Plugin } from "@lattice-php/lattice";
import type { RichEditorExtensionRegistry } from "@lattice-php/form/rich-editor";
import { Mention } from "./tiptap/mention";
export const appPlugin = {
name: "app",
extensions: {
"form.rich-editor": {
mention: {
extensions: (props) => [Mention.configure({ triggers: props.triggers ?? ["@"] })],
toolbar: () => [
{
icon: "at-sign",
key: "mention",
label: "Mention",
isActive: (editor) => editor.isActive("mention"),
run: (editor) => editor.chain().focus().insertContent("@").run(),
},
],
},
} satisfies RichEditorExtensionRegistry,
},
} satisfies Plugin;

A definition can contribute three things, all optional: extensions (Tiptap instances), starterKit (options merged into the one shared StarterKit — how the built-in marks re-enable features), and toolbar (buttons, or a component for custom controls like the heading dropdown; ToolbarIconButton is exported from the same entry point so custom controls match the built-in styling). Toolbar contributions from different extensions are separated automatically; definitions sharing a group render side by side.

For an extension that only exists client-side, skip the PHP class entirely: pass its wire type as a string — ->extensions([Bold::make(), 'mention']) — and it serializes as {"type": "mention", "props": {}}. Unknown types the client has no definition for are skipped (with a console warning in dev). See Registry and types for how generated types and the EditorExtensionProps augmentation give props a concrete shape on the client.

An extension that adds its own document nodes — not just toolbar behavior — needs a server-side counterpart too: something has to teach RichContent how to render the node, decide what’s safe to keep in a stored document, and validate any references it holds. EditorExtension exposes five seams for that, each with a no-op default so a toolbar-only extension can ignore all of them:

  • serverExtensions() — contributes tiptap-php schema classes, so RichContent can parse the node and render it to HTML via their renderHTML().
  • prepareDocument() — transforms a document once on its way out of the server (prefill and display), the place to batch-resolve stored references into human-readable attrs.
  • ephemeralAttributes() — declares which node attrs are outbound-only display data, not part of the canonical stored document.
  • configureSanitizer() — extends the HTML sanitizer with the elements/attrs the extension’s renderHTML() emits.
  • validateDocument() — validates the extension’s nodes in a submitted document (e.g. that a referenced id still exists); returned messages become field errors.

Take a callout extension — a {id, tone} node that renders as an <aside> and resolves id to a label pulled from wherever those labels live:

use Illuminate\Support\Collection;
use Lattice\Form\RichContent;
use Lattice\Form\RichEditor\Attributes\AsEditorExtension;
use Lattice\Form\RichEditor\EditorExtension;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
#[AsEditorExtension('callout')]
final class Callout extends EditorExtension
{
protected array $serverTypes = ['callout'];
public function serverExtensions(): array
{
return [new CalloutNode];
}
public function ephemeralAttributes(): array
{
return ['callout' => ['resolvedLabel']];
}
public function prepareDocument(array $document): array
{
$ids = array_column(array_column(RichContent::make($document, extensions: [$this])->nodes('callout'), 'attrs'), 'id');
$labels = Topic::whereIn('id', array_unique($ids))->pluck('name', 'id');
return $this->injectLabels($document, $labels);
}
public function configureSanitizer(HtmlSanitizerConfig $config): HtmlSanitizerConfig
{
return $config->allowElement('aside', ['data-callout', 'data-tone']);
}
public function validateDocument(array $document): array
{
$ids = array_column(array_column(RichContent::make($document, extensions: [$this])->nodes('callout'), 'attrs'), 'id');
$missing = array_diff($ids, Topic::whereIn('id', $ids)->pluck('id')->all());
return array_map(fn (int $id): string => "Callout {$id} does not exist.", $missing);
}
private function injectLabels(array $node, Collection $labels): array
{
if (($node['type'] ?? null) === 'callout') {
$node['attrs']['resolvedLabel'] = $labels[$node['attrs']['id']] ?? '';
}
if (isset($node['content'])) {
$node['content'] = array_map(fn (array $child): array => $this->injectLabels($child, $labels), $node['content']);
}
return $node;
}
}

Topic above is your own model — the extension only knows the id a user picked; resolving it to a label is application logic, and ->nodes('callout') is what lets it happen once per document instead of once per node. nodes() walks the schema-filtered document, so pass the extension along with extensions: [$this] as above: it works whether the extension is field-scoped or registered app-wide. A bare RichContent::make($document) falls back to the app-wide registry instead — for a field-scoped extension the registry doesn’t know, the schema filter strips its nodes and ->nodes() silently returns an empty list.

serverExtensions() returns the matching tiptap-php Node, which owns the attrs schema and the HTML it renders to:

use Tiptap\Core\Node;
use Tiptap\Utils\HTML;
final class CalloutNode extends Node
{
public static $name = 'callout';
public function addAttributes(): array
{
return ['id' => ['default' => null], 'tone' => ['default' => null]];
}
public function renderHTML($node, $HTMLAttributes = []): array
{
return ['aside', HTML::mergeAttributes($HTMLAttributes, [
'data-callout' => (string) ($node->attrs->id ?? ''),
'data-tone' => $node->attrs->tone ?? null,
]), 0];
}
}

Register it like any extension (see Custom extensions) and activate it on a field with ->withExtensions(Callout::make()).

resolvedLabel above never reaches storage. Declare it in ephemeralAttributes(), inject it in prepareDocument(), and toArray() — the canonical form RichEditor casts submitted values into — strips it back out before the document is persisted:

toHtml() sanitizes its output with Symfony’s HtmlSanitizer, which strips anything it wasn’t explicitly told to keep.

RichContent::make($document)->toHtml() needs no $extensions argument — called bare, it renders every extension registered app-wide via EditorExtensionRegistry, which is the usual way to display a stored document outside the context of the RichEditor field that produced it (an index page, an email, an API response):

use Lattice\Form\RichContent;
$html = RichContent::make($storedDocument)->toHtml();

->nodes('callout') walks the canonical document depth-first and returns every node of that type — what prepareDocument() and validateDocument() use above to batch-resolve and check ids, and what application code reaches for to sync references (e.g. attaching the callout’s target as a related model) without hand-rolling the tree walk.

RichEditor shares label, default value, required, disabled, read-only, and visibility options with every field — see Fields. For validation and conditional behavior, see Validation and Conditional fields.