mostafaznv/nova-ckeditor
CKEditor 5 field for Laravel Nova that adds a full-featured WYSIWYG editor to your resources, with image and file uploads through Laravel Storage, media embeds, source editing, and multi-language support. Simple to install and configure.
You should have a working CKEditor field saving real content in a Nova resource within about twenty minutes. Do these in order.
1. Pin the package against your Nova major. Nova 4 and Nova 5 are served by different package lines, so check the laravel/nova constraint in composer.json before you run anything:
composer require mostafaznv/nova-ckeditor
If you are on Laravel 13, take 7.9.0 or newer — that support arrived in a single external PR, so verify it on staging rather than assuming it.
2. Publish config and assets. The service provider is auto-discovered; you only need the publish step:
php artisan vendor:publish --provider="Mostafaznv\NovaCkEditor\FieldServiceProvider"
That yields config/nova-ckeditor.php and drops the compiled CKEditor 5 bundle under public/vendor/. That bundle is the code the browser actually runs — remember that, because it is the part that silently goes stale later.
3. Fix the column before you touch the field. The editor emits HTML, and HTML is bulky. TEXT caps at 64KB on MySQL and a decent article with inline formatting will hit it:
$table->longText('content')->nullable();
4. Add the field, with media turned off for the first pass.
use Mostafaznv\NovaCkEditor\CKEditor;
public function fields(NovaRequest $request): array
{
return [
CKEditor::make('Content', 'content')
->fileManager(false)
->language('en'),
];
}
5. Only now wire up storage, and only if you actually need images. php artisan storage:link, a disk that is publicly readable, and the disk/path/URL keys in the config matched to it.
Where to look first: config/nova-ckeditor.php is the source of truth for build variant, upload disk and path, allowed file types, and default toolbar. The CKEditor field class is the per-field override layer sitting on top of it. Read both before you start chaining options — most confusion comes from not knowing which of the two wins.
First use case: one content column on one low-consequence resource — a blog post body or help-article body — with fileManager(false) and no uploads enabled. Prove the round trip: type, save, read back from the database, render, reopen the form, confirm the HTML survives unchanged. Expand once that is boring.
Build the field in one place. The package is designed to let every resource chain its own options, and that is exactly how a codebase ends up with five subtly different editors. Wrap it:
final class Editors
{
public static function content(string $attribute = 'content'): CKEditor
{
return CKEditor::make('Content', $attribute)
->language(config('app.locale'))
->fileManager(false)
->rules(['nullable', 'string']);
}
}
When a Nova major breaks the field, or when you swap the package out, you edit one method instead of auditing a dozen resources.
Choose the build deliberately and record why. Classic, Inline, Balloon, and Document are not cosmetic variations. Inline and Balloon float a toolbar near the caret and clip badly inside Nova's scrollable panels; Document is heavier and aimed at long-form bodies. The build also determines the shape of the HTML that gets stored, so treat the choice as part of your content schema.
Customize the toolbar per field, not globally. Metadata, summary, and callout fields want a trimmed set (bold, italic, link, lists). Only the long-form body needs tables, media embeds, and headings. A ->toolbar(...) override plus a config default keeps short fields from scaring editors with irrelevant buttons.
Sanitize on write, in one place. The field stores exactly what the browser posts. Put an HTML purifier with an explicit tag/attribute allowlist in a model mutator or a saving observer:
protected static function booted(): void
{
static::saving(function (Post $post) {
$post->content = Purifier::clean($post->content, 'post');
});
}
Re-sanitize at render if the trust level of the author can change over time. Do this on day one — retrofitting a purifier onto content that is already published and hand-edited is a much nastier job.
Decide the upload policy before flipping the switch. When you enable images or fileManager(true), settle four things up front: disk (object storage over local), path prefix (namespaced per model or per user so URLs are not guessable), max size, and a MIME allowlist. Enforce the allowlist on the server. The client-side accept attribute on the field is a hint to the editor, not a security control.
Use read-only mode on the detail view. ->readOnly() on the detail screen renders the stored HTML with the same typography and spacing it was authored in, instead of you echoing a raw string into a Text field and hoping it looks right. Cheapest consistency win in the package.
Test at the HTTP layer, not in the browser. Driving CKEditor from a browser test is slow and flaky. Instead: POST a known HTML payload to your resource's store/update endpoint, assert the persisted value, assert the sanitizer dropped a <script> or onclick, and assert the detail view renders the content. One representative document per resource is enough coverage.
Import legacy content through the same sanitizer. If you are migrating existing rich text into these columns, run it through the identical purifier config first. Otherwise you seed rows that your own sanitizer will quietly mangle the first time somebody opens and saves them.
Upgrade the framework and the field in one PR. Bump Nova, Laravel, and this package together, re-publish assets, then smoke test four things in order: field renders, toolbar appears, save persists, uploaded image resolves through its URL. That is a five-minute check that catches the whole class of package upgrade failures.
The published bundle goes stale silently. After composer update, the JS under public/vendor/ may no longer match the PHP side. Symptoms: the editor mounts but throws console errors, or toolbars show plugins you removed from config. Remedy is re-publish, php artisan nova:publish, then a hard refresh in the browser.
The text-column truncation bites in week one. Your migration says longText, but an older table still has text, and content cuts off at 64KB with no error anywhere. Run SHOW CREATE TABLE before blaming the editor.
A Content-Security-Policy will blank the editor. CKEditor 5 injects inline styles, loads fonts, and may use blob URLs. Under a strict CSP you need style-src 'unsafe-inline', the asset origin in script-src, and your upload domain in img-src. The tell is an empty editor rectangle plus a CSP violation in the console.
storage:link is trivially skippable and hard to diagnose. Uploads succeed and files land on disk, but every image renders broken because the symlink is absent or points at an old path. It is idempotent — put it in the deploy script and stop thinking about it.
The package is MIT; CKEditor 5 is not necessarily. The shipped build carries its own terms (GPL, or commercial for commercial builds and premium plugins). Collaboration, comments, and track changes are commercial CKEditor features that this field does not provide. Confirm what you are actually distributing.
The file manager is a browser, not a policy. fileManager(true) lets editors pick from previously uploaded files. It enforces nothing about what gets stored. Extension, MIME, and size limits must live in config and be re-checked server-side, and you should rate-limit uploads if lower-trust admins can reach the resource.
Changing the toolbar mutates stored content. Remove the table plugin and every existing document containing a table loses that structure the next time a human opens and saves it. Lock your plugin set early. If it must change, migrate the affected content rather than letting whoever opens an old article first silently rewrite it.
Custom plugins require a custom build. You cannot drop a CKEditor plugin into node_modules and expect the published bundle to know about it. Adding one means rebuilding the CKEditor 5 bundle and republishing. That is fine if someone owns the frontend build; it is a quiet blocker if nobody does.
Config drift is the failure mode this package invites. Because every resource can chain its own options, two fields that look identical to an editor can persist different HTML with different tag support. Catch it in review, and prevent it with the factory.
Debugging checklist when the editor does not appear:
nova:publish run after the last install?hideFromIndex() or a display condition?CKEditor::make() attribute name match the actual database column?Extension points, in preference order: per-field fluent options → published config/nova-ckeditor.php → your own subclass extending CKEditor → a custom CKEditor build. Reaching for a custom build on day one is premature. Reaching for it on day one hundred, because you never centralized configuration, is the far more common regret.
Write down the fallback. With a single maintainer and near-zero dependents, assume the package could stop tracking Nova. The realistic plan: keep every field behind your factory, store portable HTML in longText rather than any editor-proprietary JSON, and accept that a future Nova major may push you back to Textarea or a self-maintained fork. Portable HTML survives all three outcomes; editor-specific payloads survive none of them.
How can I help you explore Laravel packages today?