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
TEXTcolumns.- Livewire v3 Stability: Encapsulate editor instances inside Alpine.js components using
wire:ignoreto 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:
- Official Package Link: CKEditor 5 v49.0.0 ZIP Download
- Official Documentation: CKEditor 5 Self-Hosted Documentation
Extract the ZIP file into your application's public/ directory with the following structure:
public/ └── assets/ └── vendor/ └── ckeditor5/ ├── ckeditor5.css ├── ckeditor5.umd.js └── translations/ Verify that the main files load directly in your browser:
http://localhost/assets/vendor/ckeditor5/ckeditor5.csshttp://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 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); } } } 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 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'); }); 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(/"/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> 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:Source:https://dev.to/shahibur_rahman_6670cd024/self-hosted-filament-v3-ckeditor-5-integration-in-laravel-13-5a2m