I was building an internal CRM on Filament v4 where the notes field needed three things at once: @user mentions that actually resolve to records, a reusable "callout" block writers could drop into copy, and {{ order.total }} merge tags for the email-template editor. The v4 docs cover each feature in isolation, but nothing shows them wired together — and the JSON storage format plus a Blade renderer for it barely gets a mention. Here's the whole thing, end to end.
The first thing to know: the Filament v4 rich editor is a complete rebuild on TipTap, replacing the old Trix-based editor. That swap is what unlocks mentions, custom blocks, and merge tags as first-class configuration on the RichEditor field. If you're still getting a panel and resource stood up, my zero-to-production Filament v4 dashboard guide covers the groundwork this article builds on.
Add the RichEditor field to a resource#
Start with the field itself. RichEditor::make() drops straight into any Filament form schema and behaves like every other field — it hydrates, validates, and dehydrates through the normal form lifecycle. No special-case wiring in the resource.
use Filament\Forms\Components\RichEditor;
RichEditor::make('content')
->label('Body')
->required()
->columnSpanFull();
Out of the box this stores sanitized HTML in the content column. That's fine for display-only content, but it's the wrong choice the moment you need to process the document server-side — which is exactly what mentions, blocks, and merge tags require. So the next step changes the storage format.
Store the content as JSON for round-tripping#
Switch to JSON storage with ->json(). The editor then persists TipTap's structured document format instead of an HTML string. This matters because mentions store only an id, custom blocks store their configuration, and merge tags store a token node — all of which you want as structured data you can re-render, not as baked HTML.
use Filament\Forms\Components\RichEditor;
RichEditor::make('content')
->json()
->required();
Cast the column to array on the model so Eloquent encodes and decodes it for you. Use the casts() method rather than the $casts property, matching the v4 convention.
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'content' => 'array',
];
}
}
One trap that bites people immediately: once content is JSON you can no longer echo it in a Blade view. The raw value is a TipTap node tree, not markup. You have to render it through RichContentRenderer (covered below), otherwise you'll dump a JSON blob onto the page.
Wire @mentions to the User model#
Mentions are configured with ->mentions(), passing one or more MentionProvider instances. The trigger character — @, #, whatever — is the first argument to MentionProvider::make(). For a static list you can hand it ->items([id => label]), but the useful case is a live database search, which uses two callbacks: getSearchResultsUsing() runs as the user types, and getLabelsUsing() re-resolves labels when the saved content is loaded back into the editor.
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\RichEditor\MentionProvider;
use App\Models\User;
RichEditor::make('content')
->json()
->mentions([
MentionProvider::make('@')
// Runs on every keystroke after the trigger character.
->getSearchResultsUsing(fn (string $search): array => User::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(10)
->pluck('name', 'id')
->all())
// Re-hydrates labels from the stored ids when the form loads.
->getLabelsUsing(fn (array $ids): array => User::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all()),
]);
The detail worth burning into memory: only the mention's id is stored in the document. That's why getLabelsUsing() is mandatory — without it the editor has no way to show "Jane Doe" again after a reload, and your rendered output has nothing to link to. It also means you never have stale display names baked into old content; the label is resolved fresh every time.
Define a custom callout block#
Custom blocks are reusable, structured chunks a writer inserts from a side panel — a callout box, a hero section, a two-column layout. Generate one with Artisan, then register the class on the field with ->customBlocks().
php artisan make:filament-rich-content-custom-block CalloutBlock
A block extends RichContentCustomBlock. The two required static methods are getId() and getLabel(). To collect data from the writer, override configureEditorAction() and give it a schema — this is a standard Filament action modal, the same machinery I covered in custom action confirmations and notifications. toPreviewHtml() renders the in-editor preview, and toHtml() produces the final published markup.
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor\RichContentCustomBlock;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
class CalloutBlock extends RichContentCustomBlock
{
public static function getId(): string
{
return 'callout';
}
public static function getLabel(): string
{
return 'Callout box';
}
public static function configureEditorAction(Action $action): Action
{
return $action
->modalDescription('Configure the callout')
->schema([
TextInput::make('title')->required(),
Textarea::make('body')->required(),
]);
}
/**
* @param array<string, mixed> $config
*/
public static function toPreviewHtml(array $config): string
{
return view('filament.rich-content.callout-preview', [
'title' => $config['title'],
'body' => $config['body'] ?? '',
])->render();
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $data
*/
public static function toHtml(array $config, array $data): string
{
return view('filament.rich-content.callout', [
'title' => $config['title'],
'body' => $config['body'],
])->render();
}
}
Register it on the field:
RichEditor::make('content')
->json()
->customBlocks([
CalloutBlock::class,
]);
Note that every overridable method on the block is static, and toHtml() takes two arguments — $config (what the writer entered in the modal) and $data (extra context you pass in at render time, like a category URL). Mixing those up is the most common reason a block renders blank.
Register merge tags for email templates#
Merge tags are the {{ name }} placeholders an email-template editor needs. Register them with ->mergeTags(), passing either a plain list or an associative array of token => label for friendlier display names in the side panel. The labels are display-only; they're never stored.
RichEditor::make('content')
->json()
->mergeTags([
'name' => 'Full name',
'order.total' => 'Order total',
'today' => "Today's date",
]);
The thing the docs don't shout about: merge tag values are not resolved in the editor at all. The editor only stores the token. You supply the actual values at render time on the RichContentRenderer, which keeps the template reusable across every order, user, or date you throw at it. That separation is the whole point — write the template once, resolve it per-recipient later.
Render the saved content in a Blade view#
This is the step the brief's reference docs skip. To turn stored JSON back into HTML you use RichContentRenderer. It resolves custom blocks, mentions, and merge tags in one pass, and — importantly — sanitizes the output, so you're not hand-rolling the XSS protection you'd otherwise need when rendering user HTML (the same care I applied to HTML results in Filament global search).
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Filament\Forms\Components\RichEditor\MentionProvider;
use App\Models\User;
$html = RichContentRenderer::make($post->content)
->customBlocks([
CalloutBlock::class,
])
->mergeTags([
'name' => $post->user->name,
'order.total' => money($post->order->total),
'today' => now()->toFormattedDateString(),
])
->mentions([
MentionProvider::make('@')
->getLabelsUsing(fn (array $ids): array => User::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all())
->url(fn (string $id, string $label): string => route('users.show', $id)),
])
->toHtml();
In Blade you can skip the explicit toHtml() call and echo the renderer directly — it implements Htmlable and outputs sanitized markup. Wrap it in a prose container so the typography matches the rest of your content.
<div class="fi-prose">
{{ \Filament\Forms\Components\RichEditor\RichContentRenderer::make($post->content)->customBlocks([\App\Filament\RichContent\CalloutBlock::class]) }}
</div>
If you're repeating that configuration in more than one place, move it onto the model with the InteractsWithRichContent trait and a setUpRichContent() method, then render with {!! $post->renderRichContent('content') !!}. That keeps the editor and the renderer reading from a single definition, so they can never drift apart.
Test the editor with Pest#
Treat the rich editor like any other Livewire form field in tests: fill it through fillForm() and assert against the saved record. Because you're storing JSON, assert on the decoded array shape rather than a string match — that's far more robust than comparing serialized HTML. If you're formalising conventions across the suite, architecture tests in Pest pair well with feature tests like this.
use App\Models\Post;
use App\Filament\Resources\PostResource\Pages\CreatePost;
use Livewire\Livewire;
it('saves rich editor content as json', function () {
$document = [
'type' => 'doc',
'content' => [[
'type' => 'paragraph',
'content' => [['type' => 'text', 'text' => 'Hello world']],
]],
];
Livewire::test(CreatePost::class)
->fillForm(['content' => $document])
->call('create')
->assertHasNoFormErrors();
expect(Post::first()->content)
->toBeArray()
->and(Post::first()->content['type'])->toBe('doc');
});
Run it with php artisan test --compact --filter=saves_rich_editor_content and you've got a regression guard around the storage contract.
Gotchas and Edge Cases#
The JSON-cannot-be-echoed trap is the big one: store JSON, then forget to render through RichContentRenderer, and you ship a JSON blob to the browser. If you echo content yourself anywhere, it's your job to sanitize it — RichContentRenderer, TextColumn, and TextEntry all sanitize automatically, but a raw {!! $post->content !!} does not.
Mentions store only the id, so a deleted user leaves a dangling reference. Guard getLabelsUsing() and your url() closure against missing records rather than assuming every id still resolves. And because url() output goes straight into the href, wrap any user-derived portion in Str::sanitizeUrl().
On custom blocks, remember toHtml() receives two arrays and every method is static — a block that renders blank is almost always reading $data where it meant $config. On merge tags, closure values are resolved once and cached for the rest of that render pass, so don't rely on a merge-tag closure running per-occurrence with side effects.
Wrapping Up#
You now have the full loop: a JSON-backed RichEditor with database-driven @mentions, a reusable callout block, merge tags resolved at render time, a sanitizing RichContentRenderer, and a Pest test pinning the storage shape. Build the block library out next — a hero, a two-column layout, a video embed — since each one is just another RichContentCustomBlock. If you want to go further with bespoke inputs, my walkthrough on building a custom Filament v4 form field is the natural next step.
FAQ#
What is the new rich editor in Filament v4?
It's a complete rebuild of Filament's rich text editor on top of TipTap, replacing the older Trix-based editor. The rewrite is what makes searchable mentions, custom embedded blocks, and merge tags available as first-class configuration on the RichEditor field, and it can store its output as either sanitized HTML or structured TipTap JSON.
How do I add @mentions to a Filament rich editor?
Call ->mentions() on the field and pass a MentionProvider. Set the trigger character as the first argument to MentionProvider::make('@'), then provide getSearchResultsUsing() to search your records as the user types and getLabelsUsing() to re-resolve display names when saved content loads. Only the matched record's id is stored in the document.
Can the Filament rich editor save JSON instead of HTML?
Yes. Add ->json() to the field and it stores TipTap's structured JSON document instead of an HTML string. Cast the model column to array so Eloquent handles encoding. Remember that JSON content cannot be echoed directly in Blade — you must render it through RichContentRenderer.
What are custom blocks in the Filament TipTap editor?
Custom blocks are reusable, structured pieces of content a writer inserts from the editor's side panel, such as a callout box or hero section. You define one by extending RichContentCustomBlock and implementing getId(), getLabel(), an optional modal schema via configureEditorAction(), a toPreviewHtml() for the in-editor preview, and toHtml() for the final output.
How do I render merge tags inside a Filament rich editor?
Register the available tags on the field with ->mergeTags(['name', 'today']), which lets writers insert {{ name }} style tokens. The actual values are not resolved in the editor — you supply them at render time by passing a mergeTags() array to RichContentRenderer, where each value can be a scalar or a closure. This keeps a single template reusable across many recipients.
Can I round-trip the Filament editor output through Markdown?
Storing as JSON gives you a structured document you can transform server-side, which is what makes round-tripping practical. You render to HTML with RichContentRenderer, and from there you can convert to other formats like Markdown or MJML with your own pass over the rendered output or the JSON node tree. Storing HTML directly makes this much harder, so reach for ->json() whenever a non-HTML target is on the table.