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: 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'),
];
}
}
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
Validation Checklist:
-
Static Files: Verify in DevTools Network tab that
ckeditor5.umd.jsreturns HTTP200from your public server disk. -
File Uploads: Drag and drop an image into the editor. Confirm that the upload posts to
/admin/pages/upload-imageand returns{ "url": "http://your-domain.test/storage/pages/..." }. - 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:ignoreprevents 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)