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.

->withoutToolbar() hides the formatting toolbar. Combined with the slash menu this gives a clean, Notion-like writing surface where blocks are inserted through / and the plus button:

RichEditor::make('notes')->withoutToolbar();

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)
SlashMenu slash-menu —

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.

With the default set, typing / at the start of a line or after a space opens a block menu at the caret — type to filter, pick with the arrow keys and Enter, dismiss with Escape (the typed / stays in the text, like in Notion). On an empty line a plus button appears left of the caret and opens the same menu.

The menu lists the block commands of the active extensions — headings at their configured levels, lists, blockquote, code block, horizontal rule, table and details. Remove the feature per field with ->withoutExtensions(SlashMenu::class) or app-wide via defaultExtensionsUsing().

Extensions contribute their own entries through commands in their client definition:

mention: {
commands: () => [
{
icon: "at-sign",
key: "mention",
label: "Mention",
keywords: ["person", "user"],
run: (editor) => editor.chain().focus().insertContent("@").run(),
},
],
},

The menu deletes the typed /query before it invokes run, so a command inserts exactly what a toolbar button would. key doubles as the translation key (form.editor.{key}) and filtering matches the translated label, the label fallback, the key and the keywords. An optional isAvailable: (editor) => boolean hides a command where it can’t run.

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 four things, all optional: extensions (Tiptap instances), starterKit (options merged into the one shared StarterKit — how the built-in marks re-enable features), 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), and commands (entries for the slash menu). 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.