DEV Community

Cover image for Self-Hosted Filament v3 CKEditor 5 Integration in Laravel 13
Shahibur Rahman
Shahibur Rahman

Posted on

Self-Hosted Filament v3 CKEditor 5 Integration in Laravel 13

Executing a self-hosted Filament v3 CKEditor 5 Integration in Laravel 13 gives developers total control over editor assets, optimal loading speeds, and reliable file uploads without reliance on third-party CDNs. Serving static assets directly from your server's public/ directory eliminates external dependencies, avoids CDN outages, and satisfies strict data privacy requirements.

While Filament v3 ships with a solid default rich text editor, enterprise applications often need CKEditor 5 for advanced table management, custom UI shortcodes, and source code editing.

In this step-by-step tutorial, you will set up a complete Filament v3 CKEditor 5 Integration by downloading the official v49.0.0 ZIP bundle, configuring a dedicated upload controller, creating a Livewire-compatible Alpine.js Blade component, and registering custom UI shortcode widgets in a Filament resource.


Why Choose a Self-Hosted Filament v3 CKEditor 5 Integration?

Before diving into the code, here is why a self-hosted implementation outperforms remote CDN scripts:

Key Architectural Benefits:

  • Complete Asset Control: Serve all editor scripts and styles locally from your public web root.
  • Optimized Database Usage: Store uploaded images directly on disk via a controller endpoint, avoiding bloated Base64 strings in database TEXT columns.
  • Livewire v3 Stability: Encapsulate editor instances inside Alpine.js components using wire:ignore to prevent re-rendering glitches during reactive updates.
  • Custom UI Widgets: Render interactive preview blocks inside the editor while persisting compact shortcode tags to storage.

Step 1: Download and Extract CKEditor 5 Assets

Download the pre-compiled, self-hosted distribution ZIP archive directly from CKEditor's distribution server:

Extract the ZIP file into your application's public/ directory with the following structure:

public/
└── assets/
    └── vendor/
        └── ckeditor5/
            ├── ckeditor5.css
            ├── ckeditor5.umd.js
            └── translations/
Enter fullscreen mode Exit fullscreen mode

Verify that the main files load directly in your browser:

  • http://localhost/assets/vendor/ckeditor5/ckeditor5.css
  • http://localhost/assets/vendor/ckeditor5/ckeditor5.umd.js

Step 2: Create the Image Upload Controller

Storing image binaries as Base64 strings inside database columns can cause performance degradation and database query errors. Instead, create a dedicated backend controller to save uploaded files to local disk storage and return the public URL.

Generate the controller using Artisan:

php artisan make:controller PageController
Enter fullscreen mode Exit fullscreen mode

Update app/Http/Controllers/PageController.php with the uploadImage method:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;

class PageController extends Controller
{
    public function uploadImage(Request $request)
    {
        try {
            $request->validate([
                'upload' => 'required|image|mimes:jpeg,png,jpg,gif,svg,webp|max:5120', // 5MB limit
            ]);

            if ($request->hasFile('upload')) {
                $targetDir = storage_path('app/public/pages');
                if (! File::exists($targetDir)) {
                    File::makeDirectory($targetDir, 0775, true, true);
                }

                // Store image on public disk
                $path = $request->file('upload')->store('pages', 'public');

                // CKEditor expects a JSON object containing a 'url' key
                return response()->json([
                    'url' => asset('storage/' . $path)
                ]);
            }

            return response()->json([
                'error' => ['message' => 'No image file attached.']
            ], 400);

        } catch (\Exception $e) {
            return response()->json([
                'error' => ['message' => 'Server upload error: ' . $e->getMessage()]
            ], 500);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Register the Upload Route and Storage Link

Ensure public access to public storage files by running the storage link command:

php artisan storage:link
Enter fullscreen mode Exit fullscreen mode

Next, add the route inside routes/web.php under the web and auth middleware group to enforce session and CSRF security:

use App\Http\Controllers\PageController;
use Illuminate\Support\Facades\Route;

Route::middleware(['web', 'auth'])->group(function () {
    Route::post('/admin/pages/upload-image', [PageController::class, 'uploadImage'])
        ->name('pages.upload-image');
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Build the Alpine-Powered Blade Component

Create a custom Blade view to load local static assets, handle Livewire two-way data binding via Alpine.js, implement custom XHR upload adapters, integrate shortcode widgets, and handle Filament dark mode styles.

Create resources/views/filament/forms/components/ckeditor-with-widgets.blade.php:

<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
    @assets
        <link rel="stylesheet" href="{{ asset('assets/vendor/ckeditor5/ckeditor5.css') }}">
        <script src="{{ asset('assets/vendor/ckeditor5/ckeditor5.umd.js') }}"></script>
    @endassets

    <style>
        .ck.ck-editor {
            width: 100% !important;
            border-color: #3f3f46 !important;
        }
        .ck.ck-editor__top,
        .ck.ck-editor__top .ck-sticky-panel__content,
        .ck.ck-editor__top .ck-sticky-panel .ck-toolbar,
        .ck.ck-toolbar {
            flex-wrap: wrap !important;
            background-color: #27272a !important;
            border-color: #3f3f46 !important;
            box-shadow: none !important;
        }
        .ck.ck-toolbar .ck-button,
        .ck.ck-toolbar .ck-dropdown__button {
            color: #f4f4f5 !important;
        }
        .ck.ck-toolbar .ck-button:hover,
        .ck.ck-toolbar .ck-dropdown__button:hover {
            background-color: #3f3f46 !important;
        }
        .ck.ck-toolbar .ck-button.ck-on,
        .ck.ck-toolbar .ck-dropdown__button.ck-on {
            background-color: #3f3f46 !important;
            color: #fbbf24 !important;
        }
        .ck.ck-editor__main > .ck-editor__editable {
            min-height: 350px !important;
            background-color: #18181b !important;
            color: #f4f4f5 !important;
            border-color: #3f3f46 !important;
        }
        .ck.ck-editor__main > .ck-editor__editable.ck-focused {
            border-color: #f59e0b !important;
            box-shadow: 0 0 0 1px #f59e0b !important;
        }
        .ck-source-editing-area textarea {
            background-color: #0f172a !important;
            color: #f8fafc !important;
            font-family: monospace !important;
        }
    </style>

    <div>
        <div x-data="ckEditorComponent($wire, '{{ $getStatePath() }}')" wire:ignore>
            <div x-ref="editor"></div>
        </div>
    </div>

    @script
    <script>
        Alpine.data('ckEditorComponent', (wire, statePath) => ({
            editorInstance: null,
            activeEditingWidget: null,

            init() {
                const start = () => {
                    const CK = window.CKEDITOR_5 || window.CKEDITOR;
                    if (!CK || !CK.ClassicEditor) return;

                    const self = this;

                    // Custom Upload Adapter for Local Controller
                    class ServerUploadAdapter {
                        constructor(loader) {
                            this.loader = loader;
                        }

                        upload() {
                            return this.loader.file.then(file => new Promise((resolve, reject) => {
                                const data = new FormData();
                                data.append('upload', file);

                                const csrfToken = window.Filament?.getCsrfToken() 
                                            || document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') 
                                            || '';

                                fetch('/admin/pages/upload-image', {
                                    method: 'POST',
                                    headers: {
                                        'X-CSRF-TOKEN': csrfToken,
                                        'Accept': 'application/json'
                                    },
                                    body: data
                                })
                                .then(response => {
                                    if (!response.ok) throw new Error('Upload request failed.');
                                    return response.json();
                                })
                                .then(result => {
                                    if (result && result.url) {
                                        resolve({ default: result.url });
                                    } else {
                                        reject(result?.error?.message || 'Upload failed');
                                    }
                                })
                                .catch(error => reject(error));
                            }));
                        }
                        abort() {}
                    }

                    function ServerUploadAdapterPlugin(editor) {
                        if (editor.plugins.has('FileRepository')) {
                            editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
                                return new ServerUploadAdapter(loader);
                            };
                        }
                    }

                    // Custom Shortcode UI Block Plugin
                    class ShortcodeWidget extends CK.Plugin {
                        static get requires() { return [ CK.Widget ]; }

                        init() {
                            const editor = this.editor;

                            editor.model.schema.register('shortcodeBlock', {
                                isObject: true,
                                isBlock: true,
                                allowWhere: '$block',
                                allowAttributes: [ 'shortcode', 'widgetType' ]
                            });

                            editor.conversion.for('editingDowncast').elementToElement({
                                model: 'shortcodeBlock',
                                view: (modelElement, { writer }) => {
                                    const widgetType = modelElement.getAttribute('widgetType') || 'ui_block';
                                    const container = writer.createContainerElement('div', {
                                        class: 'shortcode-widget-container',
                                        style: 'margin: 12px 0; user-select: none;'
                                    });

                                    const contentBox = writer.createRawElement('div', { class: 'shortcode-preview-box' }, (domElement) => {
                                        domElement.innerHTML = `
                                            <div style="background: #0f172a; color: #f8fafc; border: 1px solid #334155; border-radius: 8px; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center;">
                                                <div style="display: flex; align-items: center; gap: 8px;">
                                                    <span style="background: #f59e0b; color: #0f172a; font-size: 10px; font-weight: 800; padding: 3px 8px; border-radius: 4px;">UI BLOCK</span>
                                                    <code style="color: #fb7185; font-family: monospace; font-size: 12px;">${widgetType}</code>
                                                </div>
                                                <button type="button" class="edit-block-btn" style="background: #2563eb; color: #fff; border: none; font-size: 11px; padding: 4px 10px; border-radius: 4px; cursor: pointer;"> Edit Block</button>
                                            </div>
                                        `;

                                        domElement.querySelector('.edit-block-btn')?.addEventListener('click', (e) => {
                                            e.preventDefault();
                                            e.stopPropagation();
                                            self.activeEditingWidget = modelElement;

                                            const shortcodeAttr = modelElement.getAttribute('shortcode') || '';
                                            const match = shortcodeAttr.match(/type=<a href="[^"']+">"'</a>["']/);
                                            const blockType = match ? match[1] : '';

                                            wire.mountFormComponentAction('{{ $getStatePath() }}', 'insertUiBlock', {
                                                type: blockType,
                                                is_edit: true
                                            });
                                        });
                                    });

                                    writer.insert(writer.createPositionAt(container, 0), contentBox);
                                    return CK.toWidget(container, writer, { label: 'UI Shortcode Block' });
                                }
                            });

                            editor.conversion.for('dataDowncast').elementToElement({
                                model: 'shortcodeBlock',
                                view: (modelElement, { writer }) => {
                                    return writer.createContainerElement('p', {}, [
                                        writer.createText(modelElement.getAttribute('shortcode'))
                                    ]);
                                }
                            });

                            editor.conversion.for('upcast').elementToElement({
                                view: { name: 'p' },
                                model: (viewElement, { writer }) => {
                                    const rawText = viewElement.getChild(0)?.data || '';
                                    const text = rawText.replace(/&quot;/g, '"').trim();

                                    if (text.startsWith('[widget') && text.endsWith(']')) {
                                        const match = text.match(/type=<a href="[^"']+">"'</a>["']/);
                                        return writer.createElement('shortcodeBlock', {
                                            shortcode: text,
                                            widgetType: match ? match[1] : 'ui_block'
                                        });
                                    }
                                    return null;
                                }
                            });
                        }
                    }

                    // Build plugin list dynamically from global bundle
                    const plugins = [ ShortcodeWidget ];
                    ['Essentials', 'Paragraph', 'Heading', 'Bold', 'Italic', 'Underline', 'Link', 'List', 'BlockQuote', 'Table', 'TableToolbar', 'Image', 'ImageToolbar', 'ImageUpload', 'SourceEditing'].forEach(key => {
                        if (CK[key]) plugins.push(CK[key]);
                    });

                    CK.ClassicEditor.create(this.$refs.editor, {
                        licenseKey: 'GPL',
                        plugins: plugins,
                        extraPlugins: [ ShortcodeWidget, ServerUploadAdapterPlugin ],
                        toolbar: [
                            'heading', '|', 'bold', 'italic', 'underline', 'link', '|', 
                            'bulletedList', 'numberedList', '|', 
                            'insertTable', 'blockQuote', 'sourceEditing', '|', 'undo', 'redo'
                        ]
                    }).then(instance => {
                        self.editorInstance = instance;
                        instance.setData(wire.get(statePath) || '');

                        wire.$watch(statePath, value => {
                            if (value !== instance.getData()) {
                                instance.setData(value || '');
                            }
                        });

                        instance.model.document.on('change:data', () => {
                            wire.set(statePath, instance.getData());
                        });

                        Livewire.on('insertUiBlockToEditor', (event) => {
                            if (event && event.tag) {
                                instance.setData(instance.getData() + event.tag);
                                wire.set(statePath, instance.getData());
                            }
                        });

                        Livewire.on('updateUiBlockInEditor', (event) => {
                            if (self.activeEditingWidget && self.activeEditingWidget.isAttached()) {
                                instance.model.change(writer => {
                                    if (event.shortcode) writer.setAttribute('shortcode', event.shortcode, self.activeEditingWidget);
                                    if (event.widgetType) writer.setAttribute('widgetType', event.widgetType, self.activeEditingWidget);
                                });
                                wire.set(statePath, instance.getData());
                            }
                            self.activeEditingWidget = null;
                        });
                    }).catch(err => console.error('CKEditor Load Error:', err));
                };

                if (window.CKEDITOR_5 || window.CKEDITOR) {
                    start();
                } else {
                    window.addEventListener('load', start);
                }
            }
        }));     </script>
    @endscript
</x-dynamic-component>
Enter fullscreen mode Exit fullscreen mode

Step 5: Implement the Editor in Your Filament Resource

Integrate the newly created view component into your resource form definition (e.g., app/Filament/Resources/PageResource.php).

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\PageResource\Pages;
use App\Models\Page;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;

class PageResource extends Resource
{
    protected static ?string $model = Page::class;
    protected static ?string $navigationIcon = 'heroicon-o-document-text';

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Group::make()
                    ->schema([
                        Forms\Components\Section::make('Page Details')
                            ->schema([
                                Forms\Components\TextInput::make('title')
                                    ->required()
                                    ->live(onBlur: true)
                                    ->afterStateUpdated(fn ($state, callable $set) => $set('slug', \Illuminate\Support\Str::slug($state))),

                                Forms\Components\TextInput::make('slug')
                                    ->required()
                                    ->unique(ignoreRecord: true),
                            ]),

                        Forms\Components\Section::make('Content Editor')
                            ->headerActions([
                                Forms\Components\Actions\Action::make('toggleHtmlCode')
                                    ->label(fn (callable $get) => $get('show_raw_html') ? 'Visual Editor' : 'HTML Code')
                                    ->icon('heroicon-o-code-bracket')
                                    ->action(fn (callable $get, callable $set) => $set('show_raw_html', ! $get('show_raw_html'))),
                            ])
                            ->schema([
                                Forms\Components\Hidden::make('show_raw_html')->default(false)->live(),

                                // Self-hosted CKEditor 5 View Field
                                Forms\Components\ViewField::make('content')
                                    ->view('filament.forms.components.ckeditor-with-widgets')                                     ->hiddenLabel()
                                    ->required()
                                    ->visible(fn (callable $get) => ! $get('show_raw_html'))
                                    ->columnSpanFull(),

                                Forms\Components\Textarea::make('content')
                                    ->hiddenLabel()
                                    ->required()
                                    ->visible(fn (callable $get) => (bool) $get('show_raw_html'))
                                    ->rows(15)
                                    ->columnSpanFull(),
                            ]),
                    ])
                    ->columnSpan(['lg' => 8]),

                Forms\Components\Group::make()
                    ->schema([
                        Forms\Components\Section::make('Publishing')
                            ->schema([
                                Forms\Components\Select::make('status')
                                    ->options([
                                        'published' => 'Published',
                                        'draft' => 'Draft',
                                    ])
                                    ->default('published')
                                    ->required(),
                            ]),
                    ])
                    ->columnSpan(['lg' => 4]),
            ])
            ->columns(12);
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListPages::route('/'),
            'create' => Pages\CreatePage::route('/create'),
            'edit' => Pages\EditPage::route('/{record}/edit'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Clear Cache and Test Your Filament v3 CKEditor 5 Integration

Clear all compiled caches to ensure Laravel registers your new routes and views:

php artisan route:clear
php artisan view:clear
php artisan config:clear
Enter fullscreen mode Exit fullscreen mode

Validation Checklist:

  1. Static Files: Verify in DevTools Network tab that ckeditor5.umd.js returns HTTP 200 from your public server disk.
  2. File Uploads: Drag and drop an image into the editor. Confirm that the upload posts to /admin/pages/upload-image and returns { "url": "http://your-domain.test/storage/pages/..." }.
  3. Reactivity: Save the form and confirm state changes persist accurately to your database record without DOM redraw glitches.

Key Takeaways

  • Asset Independence: Hosting static assets locally insulates your panel from external CDN outages.
  • Clean Storage: Processing image drops via custom controllers prevents massive Base64 strings from bloating your database.
  • Livewire Compatibility: Wrapping custom JavaScript libraries inside Alpine's wire:ignore prevents state sync conflicts during reactive form cycles.

Conclusion

Implementing a robust Filament v3 CKEditor 5 Integration gives you complete technical ownership over your administrative editing workflows in Laravel 13.

Have you extended CKEditor 5 with custom plugins or shortcodes in Filament? Share your experience or post your questions in the comments below!

Top comments (0)