Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Editor Js Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Publish assets in CI/CD, not just locally. Missing 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.
  • Never repoint this at a legacy column. Backfill new content by transforming old HTML into blocks in a one-off command, or restrict the field to new records. Editor.js overwrites on first save.
  • Unknown block types vanish silently. Most renderers return nothing for blocks they don't recognise. Register an explicit unsupported-block handler that emits a visible placeholder and logs a warning — otherwise a removed or upgraded tool deletes paragraphs of content from the public site with zero symptoms.
  • 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.
  • Sanitize on write, purge on read as defence in depth. Raw/HTML blocks and any tool that emits markup are the package's sharpest edge — a stored-XSS vector against both the Nova admin and the public site. HTMLPurifier in the save path is non-negotiable.
  • Empty content is blocks: [], not null. blank($json['blocks'] ?? null) in every renderer and Blade conditional, or you'll ship empty wrapper <div>s site-wide.
  • Pin Editor.js tool versions. Individual tools pin peer versions of Editor.js core; bumping one can break the editor mount with a cryptic console message. Lock the toolchain and test in the spike — don't npm update casually.
  • Custom tools mean a JS build step and a re-publish. Before promising an editor a bespoke block, verify in the source whether tools are registered from config or hardcoded in the Vue component. This is the most common "it's just one block type" surprise with thin Nova field packages.
  • Budget a fork on Nova majors. Nova's field API moves between 3 → 4 → 5, and with 92 stars / 0 dependents nobody downstream is catching regressions for you. Estimate 1–3 engineer-days per Nova upgrade and plan to vendor the package into packages/nova-editor-js within a year so an upstream delay never blocks a release.
  • Last write wins. There's no locking in the Nova field. Two admins editing the same article means one silently loses their work. For contended content, add an optimistic lock on updated_at (reject saves where the client's timestamp is stale) or accept and document it.
  • Paste-from-Word will look terrible unless a paste cleaner is among the enabled tools. If your content team lives in Word/Docs, enable it before launch or budget the first month for formatting tickets.
  • Validate block shape in the model, not just 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.
  • Watch the payload size. Base64 image uploads that end up inline, or long code blocks, push a single record past MySQL's max_allowed_packet on save. Cap it with max: in the rules and monitor column size.
  • Spike two days before committing. Install it, author real content, render it on a staging Blade page with the server-side renderer. If the rendering path is painful or the block contract doesn't fit your frontend, stop there — the package gives you capture, and rendering is the entire job.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource