advoor/nova-editor-js
A Laravel Nova field that brings Editor.js block-style editing to your admin panel, letting you create clean structured content and render it as HTML. Easy to install and customize for any Nova resource.
1. Install and publish — both steps, every environment.
composer require advoor/nova-editor-js
php artisan vendor:publish --provider="Advoor\NovaEditorJs\NovaEditorJsServiceProvider"
You need the config and the compiled JS/CSS copied into public/vendor. Nova field packages pre-build their assets; if you skip the publish, or forget it in your deploy pipeline, the field renders as an empty box with no console error. Add it to your deploy script before you add it to anyone's resource.
2. Create a dedicated column. Do not reuse an existing one.
Schema::table('posts', function (Blueprint $table) {
$table->json('body')->nullable();
});
protected $casts = ['body' => 'array'];
Editor.js writes its own envelope ({ time, blocks, version }). Pointing this field at an existing longtext full of HTML or Markdown means the first save silently replaces that content with JSON — unrecoverable without a backup.
3. Wire it into a low-risk resource.
use Advoor\NovaEditorJs\NovaEditorJs;
public function fields(Request $request)
{
return [
NovaEditorJs::make('Body', 'body')
->hideFromIndex()
->rules(['nullable', 'json']),
];
}
Don't put it on your most-edited resource first. Put it on a scratch resource, or on Post while it's still empty.
4. Read the source before you read the docs. The package is thin: open the published config/nova-editor-js.php to see which Editor.js tools are enabled and how they're passed to the JS bundle, then open the Vue component in resources/js/ to see exactly how value is hydrated and serialized. Those two files answer 90% of "can I configure X?" questions — and there is no third file worth reading.
5. First exercise: the round trip. Author one post with a header, a paragraph, and a list. Save. Then:
php artisan tinker
>>> Post::first()->body
Read the raw JSON array. That structure is your content contract, and everything downstream — rendering, search, API responses — is built against it. Also confirm with Post::first()->getRawOriginal('body') that what's in the DB matches what Nova sent (casts hide the string/array boundary).
The install is a morning. The real first milestone is rendering that JSON back to HTML, so scope that in parallel, not after.
Treat the field as a capture component, not a content platform. It handles editing UI and the JSON round trip. Validation, sanitization, rendering, schema versioning, and migration are yours. Decide that on day one or you'll discover it on launch day.
Normalize at write time in a saving observer.
protected static function booted(): void
{
static::saving(function (Post $post) {
$normalizer = app(BlockNormalizer::class);
$post->body = $normalizer->normalize($post->body);
$post->body_text = $normalizer->toPlainText($post->body);
});
}
normalize() should drop unknown block types (or replace with a visible placeholder), strip empty blocks, sanitize any raw-HTML tool output through a purifier, and coalesce blocks: [] to null. Doing this on read means every consumer — API, email, search, Blade — needs its own copy of the same sanitizer, and one of them will get it wrong.
Keep a plain-text shadow column. body_text gives you a readable Nova index column, working where LIKE clauses, and something to feed into Laravel Scout without parsing JSON at query time. Pair it with ->hideFromIndex() on the editor field; a JSON blob in an index table is unreadable and slow to hydrate.
Render server-side through a single service.
class EditorJsRenderer
{
public function toHtml(?array $json): string
{
if (blank($json['blocks'] ?? null)) {
return '';
}
try {
return (new EditorjsPHP($json))->render();
} catch (\Throwable $e) {
report($e);
return '';
}
}
}
Wrap it in a Blade component (<x-editor-content :blocks="$post->body" />) so views never touch the renderer directly. That gives you one place to swap editorjs-php for a custom renderer, or to add a data-block attribute for frontend styling.
Cache rendered HTML per record.
$html = Cache::rememberForever("post:{$post->id}:body:html", fn () => $renderer->toHtml($post->body));
Bust it in the same saved hook (Cache::forget(...)). Editor.js JSON is typically 2–4× larger than the HTML it produces and costs a parse + render per request. Fine on a low-traffic blog; a real cost on a help centre with thousands of reads.
If multiple clients consume the content, store both forms. JSON as the source of truth (re-renderable, editable), HTML as the fast path for the API and email. Otherwise mobile, email, and search each need their own renderer for the same JSON.
Version your own contract alongside Editor.js's.
$post->body['schema'] = 2; // bump when block semantics change
Editor.js writes its own version in the envelope; keep it. But your rendering contract will change independently — new block types, changed tool output shape. A schema number on the payload is what lets you write a migration for old posts instead of guessing what a 2024 block meant.
Configure the toolset per field, not just globally. Fewer tools means fewer block types to render, sanitize, and test. If the field exposes fluent tool config (check the Vue component and PHP field class in the source), override per instance — a "short marketing note" and a "long-form article" want different vocabularies. If it only reads from the global config, that's a decision point: accept one toolset, or fork.
Settle media config before anyone uploads. Image blocks store paths that resolve against a disk; the package config's disk must match the disk your frontend serves from and where Nova puts uploads. Also decide CDN/transform URLs now. Retrofitting after a hundred rows reference public/uploads/... means a data migration.
Make detail/index views read-only. The editing surface is useless outside the edit form. Either use a read-only flag if the field exposes one, or override displayUsing/resolveUsing to return pre-rendered HTML and pair it with Nova's Html field on the detail view. This also removes a whole class of "why did my content change when I clicked View?" confusion.
Create a small factory method so every resource is consistent.
// app/Nova/Fields/Content.php
public static function make(string $label = 'Body', string $attribute = 'body'): NovaEditorJs
{
return NovaEditorJs::make($label, $attribute)
->hideFromIndex()
->rules(['nullable', 'json', 'max:1048576']);
}
Consistency across resources matters more than per-field flexibility here, because the renderer's block contract is global.
Test the content contract, not the editor. Feature-test the renderer and normalizer with fixture JSON committed to tests/fixtures/editorjs/ — one fixture per supported block type, plus malformed JSON, empty blocks, and an unknown block type. That suite survives package swaps and Nova upgrades, and it's the only test coverage worth writing for this dependency.
Use a real json column, but know its limits. MySQL json works well with the json validation rule and gives you ->body->'$[0]' queries if you ever need them. If you anticipate many concurrent edits on very large documents, note that MySQL rewrites the whole document per update — the plain-text shadow column is what keeps reads cheap regardless.
public/vendor = blank editor, no error. Add a post-deploy smoke check that loads one edit screen and asserts the editor mounts before the deploy is considered done. This is the #1 failure mode for any Nova field package.nullable|json is your only cheap guard against malformed payloads. Beyond that, wrap rendering in try/catch with a logged fallback. One truncated row should degrade to empty content, not a 500 on the homepage.blocks: [], not null. blank($json['blocks'] ?? null) in every renderer and Blade conditional, or you'll ship empty wrapper <div>s site-wide.npm update casually.packages/nova-editor-js within a year so an upstream delay never blocks a release.updated_at (reject saves where the client's timestamp is stale) or accept and document it.json. json only proves it parses. A BlockNormalizer that asserts each block has a known type and a data array catches the "tool saved something weird" case before it reaches the renderer.max_allowed_packet on save. Cap it with max: in the rules and monitor column size.How can I help you explore Laravel packages today?