asignua / filament-rich-editor-toolkit
Free plugins for the Filament 5 RichEditor: paste cleanup for Word and Google Docs, preserved class/id/style/data-* attributes, safe iframe and video embeds, image by URL and a toolbar that stays visible on long content.
Package info
github.com/asignua/filament-rich-editor-toolkit
pkg:composer/asignua/filament-rich-editor-toolkit
Requires
- php: ^8.3
- filament/filament: ^5.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
- symfony/html-sanitizer: ^7.0|^8.0
- ueberdosis/tiptap-php: ^2.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Five free plugins for the Filament 5 RichEditor (TipTap) that fix the problems people keep
reporting in Filament's GitHub Help:
| Plugin | Fixes |
|---|---|
PasteCleanPlugin |
Pasting from Word / Google Docs drags in colours, fonts, mso-* junk and <span style> soup. |
CustomAttributesPlugin |
class, id, style and data-* written in the source view are silently stripped, in the browser and on the server. |
EmbedPlugin |
<iframe> / video embeds are removed by the schema and the sanitizer. |
ImageUrlPlugin |
No way to insert an image that lives on another site. |
StickyToolbarPlugin |
The toolbar scrolls away on long content (#17391, #16919, #11685). |
Each plugin works alone; take only what you need.
- Screenshots
- Requirements
- Installation
- Usage
- Rendering on the front end
- Configuration
- Gotchas
- Translations
- AI agents
- Testing
Screenshots
Requirements
- PHP 8.3+
- Filament 5
- Laravel 12 or 13
Installation
composer require asignua/filament-rich-editor-toolkit php artisan filament:assets
filament:assets publishes the compiled editor modules. They are loadedOnRequest(): no <script> is printed on a page
without a rich editor, the editor imports them by URL.
Optionally publish the config and the translations:
php artisan vendor:publish --tag=filament-rich-editor-toolkit-config php artisan vendor:publish --tag=filament-rich-editor-toolkit-translations
Usage
use Asignua\RichEditorToolkit\Plugins\CustomAttributesPlugin; use Asignua\RichEditorToolkit\Plugins\EmbedPlugin; use Asignua\RichEditorToolkit\Plugins\ImageUrlPlugin; use Asignua\RichEditorToolkit\Plugins\PasteCleanPlugin; use Filament\Forms\Components\RichEditor; RichEditor::make('body') ->plugins([ PasteCleanPlugin::make(), CustomAttributesPlugin::make(), EmbedPlugin::make(), ImageUrlPlugin::make(), ]) ->toolbarButtons([ ['bold', 'italic', 'link', 'cleanFormat'], ['h2', 'h3', 'bulletList', 'orderedList'], ['embed', 'imageUrl'], ]);
A toolbar button is shown only when it is listed in toolbarButtons() (cleanFormat, embed, imageUrl); an unknown
name makes Filament throw, so list only the plugins you installed.
PasteCleanPlugin
On Ctrl+V the structure stays (paragraphs, headings, lists, tables, links, bold/italic/underline) and colours, font
sizes, backgrounds and every class/id/style they carry disappear. A paste from inside an editor is never
touched (the data-pm-slice cutoff), so it composes with CustomAttributesPlugin. The marker is ProseMirror's, not
the toolkit's: a paste copied from any ProseMirror/TipTap-based app (Confluence, GitLab, another CMS) skips the
cleanup too and keeps its class/style.
The cleanFormat button runs the same cleanup on the selected fragment of existing content. Unlike Filament's
clearFormatting it keeps headings and lists (that one runs clearNodes().unsetAllMarks()). Custom blocks survive; to
keep your own sentinel links, list their prefixes:
PasteCleanPlugin::make()->keepLinkPrefixes(['/internal-link/'])
Images pasted from Word and Google Docs are removed (Filament uploads only data: images from the clipboard); add them
through the upload button.
CustomAttributesPlugin
Keeps class, id, style and further attributes you list, on paragraphs, headings, lists, tables, images, links,
details, grid, spans and generic <div> containers. It also stops legacy markup from collapsing: <div class="row"><img> <span>…</span></div> keeps its direct children, and <li><div>…</div></li> stays a list item.
// config/rich-editor-toolkit.php 'attributes' => ['data-track', 'aria-label', 'role'], 'types' => ['myCustomNode'],
The list is explicit because Symfony's HTML sanitizer, which Filament runs over every rendered rich text, cannot allow
data-* as a pattern. on* handlers, URL attributes (href, src, srcset, srcdoc, action, formaction,
ping, poster, ...), is, any name with : and framework directives (x-*, hx-*, v-*, ng-*, wire:*) are
never accepted: the panel runs Alpine over the editor, so an allowed x-init would be stored XSS against every admin.
That list is a backstop, not a whitelist; allow only what you need (data-*, aria-*, role, title).
->attributes([...]) / ->types([...]) on a plugin instance replace the config for that field only. A front-end request
never builds the form, so pass the same lists to the renderer, or the values are saved and dropped on the page:
CustomAttributesPlugin::make()->attributes(['data-track']); // the form RichEditorToolkit::renderer($post->body, attributes: ['data-track'])->toHtml(); // the page
EmbedPlugin
The "Embed" button takes a YouTube / Vimeo link (including youtu.be, Shorts, timecodes) or an https address from your
allow-list, and inserts a bare <iframe>. A video link is rebuilt from provider and id
(youtube-nocookie.com, no autoplay; an unlisted Vimeo video keeps its privacy hash), never copied. Pasted or
hand-written iframes pass the same check at once in the browser and when the HTML is parsed on the server; an iframe from
any other host does not exist as a node and is dropped. The check runs again when a node is rendered, so JSON content
(RichEditor::json(), an array handed to the renderer) cannot smuggle one in either. A host/path entry matches on a
segment boundary, and a path with dot segments (/maps/embed/../../url), %2e, %2f or a backslash is refused.
'embed' => [ 'hosts' => ['www.google.com/maps/embed'], // host or host/path/prefix, https only 'sandbox' => 'allow-scripts allow-same-origin allow-presentation allow-popups', ],
sandbox, allow, referrerpolicy and loading="lazy" are forced onto every embed on render, whatever the stored HTML
says.
ImageUrlPlugin
Inserts an <img> that is loaded from the other site, not copied. Only absolute http(s) URLs are accepted;
ImageUrlPlugin::make()->hosts(['cdn.example.com']) restricts what the dialog accepts. It is not a content filter:
an <img> typed in the source view or pasted is not checked against it. http:// is accepted (mixed content on an
https site).
StickyToolbarPlugin
A panel plugin, CSS only:
use Asignua\RichEditorToolkit\StickyToolbarPlugin; $panel->plugin(StickyToolbarPlugin::make()->offset('4rem'));
Filament 5 has RichEditor::stickyToolbar() per field; this makes it the default for every editor and fixes the case
where it silently does nothing, because a section, tab or repeater item clips with overflow: hidden (which turns it
into a scroll container that position: sticky cannot escape). Those containers are switched to overflow: clip.
Run php artisan filament:assets so the stylesheet is published.
Opt a single field out with RichEditor::make('body')->withoutStickyToolbar() (it sets data-toolkit-sticky="off", which the stylesheet respects, even over Filament's native ->stickyToolbar()).
Rendering on the front end
RichContentRenderer parses the stored HTML with the same tiptap-php schema and then sanitizes it, and both steps drop
what they do not know. Use the helper:
{!! \Asignua\RichEditorToolkit\RichEditorToolkit::renderer($post->body)->toHtml() !!}
or attach the plugins to a renderer you build yourself and render it through the toolkit:
$renderer = RichContentRenderer::make($post->body) ->customBlocks([...]) ->plugins(RichEditorToolkit::plugins()); {!! RichEditorToolkit::toHtml($renderer) !!}
This adds CustomAttributesPlugin and EmbedPlugin and sanitizes with the toolkit's own sanitizer: a copy of
Filament's config plus your attribute allow-list and, when EmbedPlugin is attached, <iframe> whose src passes the
embed allow-list. Filament's shared sanitizer is never changed, so TextColumn::html(), TextEntry::html(),
notifications and the Markdown editor keep Filament's defaults. A renderer's own toHtml() uses that shared sanitizer,
which is why RichEditorToolkit::toHtml($renderer) is needed (the same goes for HasRichContent models:
RichEditorToolkit::toHtml($post->getRichContentAttribute('body')->getRenderer())). Do not add PasteCleanPlugin
to the renderer: the cleanup happens once, at paste time.
Configuration
See config/rich-editor-toolkit.php: attributes, types, embed, paste_clean,
sticky_toolbar.
Gotchas
- Both halves, always.
CustomAttributesPluginandEmbedPluginmust be in the editor and in the renderer. Miss the renderer and the data sits in the database and vanishes on the page. - Publish the assets. A missing
filament:assetsmeans a 404 for the module URL and aconsole.errorfrom Filament's loader. Nothing else fails, the plugin is just absent. - Node types. An attribute is kept only on the node types in the list (
Extensions\CustomAttributes::TYPESplus thetypesconfig). The PHP and the JS lists are identical on purpose; a test pins it. - The sanitizer is the toolkit's own. Allowances apply only to
RichEditorToolkit::renderer()andRichEditorToolkit::toHtml(); a plainRichContentRenderer::toHtml()and every->html()column still drop<iframe>and the extra attributes. toolbarButtons(). ListcleanFormat,embed,imageUrlonly when the matching plugin is registered.
Translations
English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish. The namespace is
rich-editor-toolkit::.
AI agents
Laravel Boost guidelines ship in resources/boost/guidelines/core.blade.php.
Testing
composer test # PHP (Testbench) composer analyse # PHPStan level 8 vendor/bin/pint --test npm install npm run test:js # node --test + jsdom npm run build # recompile resources/dist (commit the result)



