kahusoftware/filament-ckeditor-field
kahusoftware/filament-ckeditor-field adds a CKEditor 5 rich text editor field to Filament forms, with configurable toolbars, image and media support, smooth Livewire integration, and easy customization for Laravel admin panels. It replaces basic textareas with a full-featured WYSIWYG editor.
1. Install the package
composer require kahusoftware/filament-ckeditor-field
Check the released tag against your Filament major version before committing (see Gotchas). If the package ships migrations, assets, or a config file, publish them:
php artisan vendor:publish --tag="filament-ckeditor-field-config" # if available
php artisan filament:assets # if it registers a Filament asset
2. Look here first, in this order
README.md on the repo — the package is deliberately small; the README is the primary API surface and typically shows the only two or three public methods you will ever call.config/ckeditor-field.php (after publishing) — toolbar/height/default options usually live here rather than in code.src/CkeditorField.php (or similarly named class) — read the class once. With a package this small you can absorb the entire public API in five minutes, and that is far more reliable than guessing at method names from blog posts.resources/js / resources/views — tells you whether CKEditor is bundled via Vite or loaded from a CDN. This matters for offline builds and CSP.3. First use case — a rich-text body on a Resource form
use KahuSoftware\FilamentCkeditorField\Forms\Components\CkeditorField;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('title')->required(),
CkeditorField::make('body')
->required()
->columnSpanFull(),
]);
}
Make sure the underlying column is text/longText, not string. Migrate before you load the form, or the editor will save and silently truncate.
4. Minimum viable verification
Create a record, type formatted text with a heading and a list, save, then re-open. If the HTML round-trips, the field's Livewire binding is working and you can move on to configuration. If it comes back empty or escaped, jump straight to Gotchas.
Toolbar and height configuration
Most usage of this package is "make the toolbar match our editorial policy." Keep it in one place — a config file or a private static method — rather than repeating the array in every resource:
// app/Support/RichText.php
public static function toolbar(): array
{
return ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', '|', 'undo', 'redo'];
}
CkeditorField::make('body')
->toolbar(RichText::toolbar()) // exact method name per README
->height(400);
Removing buttons is your cheapest defense against garbage markup. If the field is for a 200-word "summary," do not expose heading, table, or font-size controls.
Form + table + infolist triad
A rich-text column needs three different renderings, and developers routinely get the table one wrong:
// Table: never render raw HTML in a column
TextColumn::make('body')
->limit(60)
->formatStateUsing(fn (?string $state) => strip_tags((string) $state))
->tooltip(fn (?string $state) => Str::limit(strip_tags((string) $state), 300))
->html(false),
// Infolist / view page: this is where you DO render HTML
TextEntry::make('body')->html(),
Keeping the table cell plain-text avoids layout blowouts from embedded images and tables inside a 40px row.
Frontend rendering
The field stores HTML; rendering it on your public site is your responsibility, not the package's. Establish a single output path early:
{{-- resources/views/partials/rich-text.blade.php --}}
<div class="prose prose-lg max-w-none">
{!! $html !!}
</div>
Sanitize on save or on render (mews/purifier, symfony/html-sanitizer). Because the package is configured for non-premium CKEditor features only, the editor's own input filtering is decent but is not a security boundary — anyone can POST to a Livewire component.
Workflow: content models with a shared field
For multi-model rich text, wrap the field in a small factory so the toolbar policy and any file-upload disk live in one class:
final class RichText
{
public static function make(string $name = 'body'): CkeditorField
{
return CkeditorField::make($name)
->toolbar(self::toolbar())
->height(420);
}
}
Then RichText::make('body') and RichText::make('bio') (with a smaller toolbar variant) across resources. When you later need to add upload disks or a language switch, it is a one-file change.
File / image uploads
If the package exposes upload configuration, point it at a named disk and a dedicated directory, and make that disk explicit in config/filesystems.php rather than relying on the default:
CkeditorField::make('body')
->disk('public')
->directory('rich-text/' . auth()->id());
Set FILESYSTEM_DISK correctly per environment. Uploads that work locally and 404 in production are almost always a missing storage:link or a disk pointing at local instead of public.
Pairing with a resource lifecycle
Two patterns that pay off:
ArticleRevision model and snapshot body in afterSave/an observer. The package gives you no history.protected function excerpt(): Attribute
{
return Attribute::get(fn () => Str::limit(trim(strip_tags($this->body ?? '')), 160));
}
Integration tip: don't fight Livewire
Treat the editor as a form component like any other. Call ->reactive(), ->afterStateUpdated(), and ->live() on it only when you actually need them; CKEditor instances are expensive to tear down and rebuild, and reactive fields can make a long form feel sluggish.
CKEditor 5 licensing is not the package's MIT license. The package is MIT, but the bundled CKEditor 5 build is GPL-2.0-or-later (or commercial). This is fine for most GPL-compatible or internal projects, but if you are shipping a closed-source SaaS product, have that conversation before you build the content model around this field. The "non-premium features" framing means no real-time collaboration, no commercial plugin set, no official export-to-Word/PDF — so no surprise licensing tail from premium add-ons.
Low bus factor — pin and plan for replacement. 16 stars and zero dependents means you are near the front of the adoption curve. Pin the version in composer.json ("^1.0" rather than "*"), and wrap your usage behind the RichText::make() factory above so swapping to filament-tiptap-editor or Filament's built-in RichEditor is a one-file diff. Watch for the Filament 4 upgrade: packages at this scale frequently lag a major Filament release by months.
Version compatibility is the #1 install failure. Filament v3 and v4 form component APIs differ. Check composer.json in the package for the filament/filament constraint, and if your app is on a newer Filament, test in a scratch project first. Symptom: field renders but the JS never initializes, or Class not found on the namespaced component.
Assets not loading in production. If the editor renders as a bare <textarea>, the CKEditor JS bundle isn't reaching the browser. Run php artisan filament:assets, confirm the published files exist under public/, clear php artisan optimize:clear, and check the network tab for a 404. On aggressive CSP setups, add the CDN or local bundle to script-src.
HTML escaping surprises. Two failure modes: raw tags showing on the frontend (you forgot {!! !!} or ->html()), or the editor content double-escaping after a save (<p>). The second usually means you are escaping on write and read, or a mutator is running e()/strip_tags on the way in. Strip-tags belongs on the excerpt/table path only; the stored body must stay verbatim HTML.
Dark mode and prose styling. CKEditor brings its own theme. In Filament's dark mode the toolbar can look mismatched, and on the frontend your prose classes only apply if @tailwindcss/typography is installed and the content is scanned — add the rich-text Blade partial to Tailwind's content globs. Content written by a CKEditor user in v1 won't match your v0 prose config; do a visual pass on existing records after any CSS change.
columnSpanFull() is usually required. In a default two-column Filament form, the editor gets squeezed and the toolbar wraps into three rows. Add it.
Content width is not editable width. Users will paste from Word/Google Docs, and the editor's WYSIWYG promise breaks the moment your frontend container is narrower than the editor pane. Give the field the same max-width as the rendered article (->height() plus a narrower preview pane if the package supports it), otherwise editors will complain about "wrong line breaks" that are really just different container widths.
Debugging checklist, in order: (1) is body a text column? (2) does the network tab show the CKEditor bundle? (3) is there a JS console error — usually a bad toolbar entry name, which silently kills the whole editor instance; (4) does wire:model hold the HTML after typing? (5) did you run filament:assets? Most issues land on 2 or 3.
Toolbar config is validated at runtime. One misspelled button name (e.g. bulletList vs bulletedList) throws a CKEditor ui-item-not-found-style error and blanks the toolbar or the editor. Change the toolbar one item at a time, and remember that separators are the string '|', not null.
Missing niceties you'll end up building yourself. Expect to write these on top of the package: server-side sanitization, a word counter / max-length rule (use ->maxLength() on the underlying column plus a custom validation rule against strip_tags length), upload cleanup when a record is deleted (an observer removing files from the configured disk/directory), and change history. None of these are exotic — just budget for them rather than assuming the field provides them.
How can I help you explore Laravel packages today?