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…')- columnWidth:"md"
- conditions:null
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Article"
- labelAction:null
- name:"article"
- placeholder:"Write your article…"
- prefillRefreshOn:null
- prefillResetOn:null
- readOnly:false
- required:false
- tooltip:null
- value:null
- levels:[1,2,3,4,5,6]
- alignments:["left","center","right","justify"]
- openOnClick:false
- protocols:["http","https","mailto"]
- cols:3
- rows:3
- withHeaderRow:true
- emojis:["😀","😅","😂","🥳","😎","🤔","👍","🙏","🔥","🎉","🚀","💡","✅","❌","⭐","❤️"]
Stored value
Section titled “Stored value”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
Section titled “Placeholder”->placeholder() sets the muted hint shown while the editor is empty.
Extensions
Section titled “Extensions”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'), ])- columnWidth:"md"
- conditions:null
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Summary"
- labelAction:null
- name:"summary"
- placeholder:null
- prefillRefreshOn:null
- prefillResetOn:null
- readOnly:false
- required:false
- tooltip:null
- value:null
- levels:[2,3]
- openOnClick:false
- 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 typeTo 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(),]);Default extensions
Section titled “Default extensions”| 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.
Custom extensions
Section titled “Custom extensions”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.
Server-side extensions
Section titled “Server-side extensions”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, soRichContentcan parse the node and render it to HTML via theirrenderHTML().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’srenderHTML()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()).
The ephemeral-attr contract
Section titled “The ephemeral-attr contract”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:
Sanitizing custom HTML
Section titled “Sanitizing custom HTML”toHtml() sanitizes its output with Symfony’s HtmlSanitizer, which strips anything it wasn’t
explicitly told to keep.
Rendering without a field
Section titled “Rendering without a field”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.
Common options
Section titled “Common options”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.