kahusoftware/filament-ckeditor-field

A basic CKEditor 5 form field configured with non-premium features.

Maintainers

Package info

github.com/Kahu-Software-LLC/filament-ckeditor-field

Homepage

pkg:composer/kahusoftware/filament-ckeditor-field

Transparency log

Statistics

Installs: 17 226

Dependents: 0

Suggesters: 0

Stars: 15

Open Issues: 0


README

Latest Version on Packagist GitHub Tests Action Status Total Downloads License

Plumb score Plumb security score Plumb maintenance score Plumb ecosystem score Scanned by Plumb

Note: This branch (1.x) is specifically for FilamentPHP 3.x. If you're using FilamentPHP 4.x, please use the 2.x branch.

Features

  • CKEditor 5 integration for FilamentPHP 3 forms
  • Works anywhere Livewire renders, including panels, repeaters and standalone components
  • Image upload support with configurable upload URLs
  • Full control over image upload handling - you implement your own upload endpoint
  • Full control over the editor configuration from config, a service provider, or per field
  • Fixed or minimum editing area height, and support for Filament's autofocus()
  • Helper for finding images removed from a document, so storage can be reconciled on save
  • Highly customizable with fluent API
  • Non-premium features only (free and open-source)
  • Easy to configure and use

Table of contents


Installation

You can install the field via composer:

composer require kahusoftware/filament-ckeditor-field

You can publish the config file with:

php artisan vendor:publish --tag="filament-ckeditor-field-config"

Usage

Basic usage:

use Kahusoftware\FilamentCkeditorField\CKEditor;

CKEditor::make('content')
    ->uploadUrl(null)

Configuration

This is the contents of the published config file:

return [
    /**
     * Image upload enabled
     */
    'upload_enabled' => true,

    /**
     * Image URL to upload to if one is not specified on the form field's ->uploadUrl() method
     */
    'upload_url' => null,

    /**
     * Everything the CKEditor instance is built from: plugins, toolbar,
     * htmlSupport, headings, styles and so on. See "Editor options" below.
     */
    'editor' => [
        'plugins' => [/* ... */],
        'upload_only_plugins' => ['ImageInsert', 'ImageUpload', 'SimpleUploadAdapter'],
        'upload_only_toolbar_items' => ['insertImage'],
        'disabled_plugins' => [],
        'disabled_toolbar_items' => [],
        'options' => [/* ... */],
    ],
];

Editor options

The full CKEditor configuration lives under the editor key of the config file. Publish the config to see and edit the complete defaults.

Any key the published config omits falls back to the package default, so a partial editor block is always safe: declaring only disabled_plugins, for example, leaves the default plugin list, toolbar and option groups intact.

Useful references while editing:

Upgrading from 1.0.x

A config file published before 1.1.0 has no editor key and keeps working unchanged: the package defaults apply, and the editor behaves exactly as it did before the configuration was extracted. To see and edit the full defaults, republish the config:

php artisan vendor:publish --tag="filament-ckeditor-field-config" --force

--force overwrites the existing file, so re-apply any upload_enabled or upload_url customizations afterwards. Alternatively, add just the editor keys you want to change; everything omitted falls back to the defaults.

Where to set them

Options resolve in four layers, and later layers win: the package defaults, then the published config file, then CKEditor::configureUsing(), then the methods on an individual field.

1. Application wide, in the published config file.

// config/filament-ckeditor-field.php
'editor' => [
    'disabled_plugins' => ['FontColor', 'FontBackgroundColor', 'Highlight'],
    'disabled_toolbar_items' => ['fontColor', 'fontBackgroundColor', 'highlight'],
],

2. Application wide, in a service provider.

use Kahusoftware\FilamentCkeditorField\CKEditor;

public function boot(): void
{
    CKEditor::configureUsing(fn (CKEditor $field) => $field
        ->disablePlugins(['FontColor', 'FontBackgroundColor', 'Highlight'])
        ->disableToolbarItems(['fontColor', 'fontBackgroundColor', 'highlight']));
}

3. On a single field.

CKEditor::make('content')
    ->editorOptions([
        'menuBar' => ['isVisible' => false],
    ])
    ->disableToolbarItems(['insertTable'])

How options merge

String-keyed arrays merge recursively, while list-shaped arrays are replaced outright. Overriding a list therefore swaps it wholesale rather than appending to it, and passing an empty array clears it.

// Replaces the seven default sizes with two, rather than adding to them.
->editorOptions(['fontSize' => ['options' => [12, 16]]])

// Leaves link.addTargetToExternalLinks untouched.
->editorOptions(['link' => ['defaultProtocol' => 'http://']])

Plugins and toolbar items

Plugins are named as strings and resolved against the JavaScript window scope at runtime. Names that are not bundled are skipped, so removing plugins is always safe while adding one requires it to be present in the bundle.

Removing a toolbar item hides its button. Removing a plugin switches the feature off entirely, which also hands whatever markup it owned back to General HTML Support. That distinction matters: an htmlSupport.disallow rule has no effect while the plugin that owns the markup is still active, because the plugin's own converters handle it first. To strip markup rather than merely hide a button, disable the plugin and disallow the markup.

CKEditor::make('content')
    ->disablePlugins(['FontColor', 'FontBackgroundColor', 'Highlight'])
    ->disableToolbarItems(['fontColor', 'fontBackgroundColor', 'highlight'])
    ->editorOptions([
        'htmlSupport' => [
            'allow' => [[
                'name' => 'js:/^.*$/',
                'classes' => true,
                'attributes' => true,
                // An explicit allowlist, rather than `true` for every style.
                'styles' => ['text-align', 'font-size', 'font-family'],
            ]],
            'disallow' => [
                ['name' => 'js:/^(font|mark)$/'],
                ['attributes' => ['bgcolor', 'color']],
                ['styles' => ['color', 'background', 'background-color']],
            ],
        ],
    ])

A toolbar item is also dropped when the plugin behind it is not in the resolved plugin list, so disabling a plugin never leaves a dead button that CKEditor would report as toolbarview-item-unavailable. The disableToolbarItems() call above is therefore optional; it is kept to show both methods side by side. Items this package does not recognise, such as buttons from a custom build, always pass through.

Separators (|) left with nothing to divide are dropped automatically, so removing items never leaves stray dividers in the toolbar.

JavaScript expressions

Some CKEditor options expect values that JSON cannot express, such as the regular expression in htmlSupport. Prefix a string with js: and it is written into the page as a bare JavaScript expression instead of a quoted string:

'name' => 'js:/^.*$/',

At runtime you can also pass a Filament\Support\RawJs instance. In the config file use the js: string form, because objects do not survive php artisan config:cache.

Note A js: value is emitted verbatim. Only use it for values you control, never for user input.


Available methods

uploadUrl(string | Closure | null $uploadUrl)

Sets the URL endpoint for image uploads. If not specified, the default upload URL from the config file will be used.

uploadUrl (Default: null)

Note: This field gives you freedom to handle image uploads yourself. You are responsible for creating your own upload endpoint that handles file validation, storage, and returns the appropriate response format. This design allows you to implement your own business logic, security measures, and storage solutions (local filesystem, S3, cloud storage, etc.).

This field uses CKEditor's Custom Upload Adapter, which requires your upload endpoint to return a JSON response containing the uploaded image URL(s).

Expected Response Format:

Your upload endpoint must return a JSON response with one of the following formats:

Single image response:

{
    "url": "https://example.com/uploads/image.jpg"
}

Responsive images response:

{
    "urls": {
        "default": "https://example.com/uploads/image.jpg",
        "500": "https://example.com/uploads/image1.jpg",
        "1000": "https://example.com/uploads/image2.jpg"
    }
}

Example Laravel Controller:

use Illuminate\Http\Request;

public function uploadImage(Request $request)
{
    $request->validate([
        'upload' => 'required|image|max:2048',
    ]);

    $path = $request->file('upload')->store('uploads', 'public');
    $url = asset('storage/' . $path);

    return response()->json([
        'url' => $url
    ]);
}

For more details, see the CKEditor Custom Upload Adapter documentation.

name(string $name)

Sets the name of the field. This will be used as the form field name.

name (Default: 'ckeditor')

placeholder(string $placeholder)

Sets the placeholder text displayed in the editor when it's empty.

placeholder (Default: 'Type or paste your content here...')

height(string | Closure | null $height)

Fixes the editing area to the given CSS height, scrolling internally once content outgrows it. Without it the editor grows with its content.

CKEditor::make('content')
    ->height('400px')

minHeight(string | Closure | null $minHeight)

Lets the editing area start at the given CSS height while still growing with its content.

CKEditor::make('content')
    ->minHeight('10rem')

editorOptions(array | Closure $options)

Merges options over the resolved editor configuration for this field. See How options merge. Can be called more than once, with later calls taking precedence.

CKEditor::make('content')
    ->editorOptions(['menuBar' => ['isVisible' => false]])

disablePlugins(array | Closure $plugins)

Removes plugins from the resolved plugin list, switching those features off. Toolbar items belonging to a removed plugin are dropped along with it.

CKEditor::make('content')
    ->disablePlugins(['FontColor', 'FontBackgroundColor', 'Highlight'])

enablePlugins(array | Closure $plugins)

Re-enables plugins that the config file or a configureUsing callback disabled.

CKEditor::make('content')
    ->enablePlugins(['Highlight'])

disableToolbarItems(array | Closure $items)

Removes items from the toolbar, leaving the underlying plugins active. Orphaned separators are dropped automatically.

CKEditor::make('content')
    ->disableToolbarItems(['insertTable', 'codeBlock'])

Cleaning up removed images

Uploaded images stay in storage when they are later deleted from the editor. That is deliberate while editing: undo and redo need the file to still exist, so the earliest safe moment to reconcile storage is when the record is saved.

CKEditor::findRemovedImages() compares the previously saved document with the one about to be saved and returns the image URLs that disappeared:

use Kahusoftware\FilamentCkeditorField\CKEditor;

$removed = CKEditor::findRemovedImages($oldHtml, $newHtml, urlPrefix: '/storage/uploads/');

The package never deletes files itself, mirroring how uploads work: you own the endpoint, so you own the cleanup. The optional urlPrefix restricts the result to your own uploads, so external or hotlinked images can never end up on a deletion list.

In a panel resource, capture the removed URLs before saving and delete after the save succeeds:

use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Kahusoftware\FilamentCkeditorField\CKEditor;

class EditPost extends EditRecord
{
    protected static string $resource = PostResource::class;

    /** @var array<int, string> */
    protected array $removedImages = [];

    protected function mutateFormDataBeforeSave(array $data): array
    {
        $this->removedImages = CKEditor::findRemovedImages(
            $this->record->getOriginal('content'),
            $data['content'] ?? null,
            urlPrefix: asset('storage/uploads'),
        );

        return $data;
    }

    protected function afterSave(): void
    {
        foreach ($this->removedImages as $url) {
            Storage::disk('public')->delete(Str::after($url, '/storage/'));
        }
    }
}

In a standalone Livewire component the same shape applies:

public function save(): void
{
    $data = $this->form->getState();

    $removed = CKEditor::findRemovedImages(
        $this->post->getOriginal('content'),
        $data['content'] ?? null,
        urlPrefix: asset('storage/uploads'),
    );

    $this->post->update($data);

    foreach ($removed as $url) {
        Storage::disk('public')->delete(Str::after($url, '/storage/'));
    }
}

Deleting only after a successful save keeps the files available if validation or the save itself fails.

Inherited field methods

The field extends Filament's base Field, so everything a standard form field supports works here without package code, including:

CKEditor::make('content')
    ->label('Body')
    ->autofocus()          // focuses the editor once it has initialised
    ->required()
    ->disabled()
    ->hidden()
    ->helperText('Shown under the field')
    ->columnSpanFull()

See the Filament form field documentation for the full list.

Testing

composer test

The test suite uses PestPHP and includes unit tests for field instantiation, method chaining, and configuration, as well as feature tests for rendering the field within Livewire components.


Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please email hello@kahusoftware.com any security vulnerabilities to ensure they're promptly addressed.

Credits

License

The MIT License (MIT). Please see License File for more information.

* This open-source plugin is not affiliated with, endorsed, or sponsored by CKSource, and any references to CKEditor are solely for descriptive purposes under their respective copyrights and trademarks.

We do encourage you to check out CKEditor's premium features for your own implementation of CKEditor as the developers have worked hard to bring us a wonderful rich editor.