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

Livewire Editorjs Laravel Package

maxeckel/livewire-editorjs

A Laravel Livewire component that brings the block-style Editor.js editor to your app. Render and edit JSON block content, save HTML, upload images via Laravel storage, and extend it with custom tools. Clean integration with validation and simple Blade usage.

View on GitHub
Deep Wiki
Context7

Getting Started

Install with version awareness. v1.5.0 adds explicit Laravel 10 support and bumps Editor.js from 2.22.3 to 2.26.5, so a Laravel 10 / Livewire 2 app is now a supported target. There is still no signal of Livewire 3 or Laravel 11 support, so on a Livewire 3 / Laravel 11 app expect friction and be prepared to constrain Livewire or fork/vendor the handful of shipped files.

composer require maxeckel/livewire-editorjs

If Composer fights you on Livewire 3, treat the fork as the likely outcome. The package surface remains small: a service provider, config file, Blade view, Livewire component, and JS entry point.

Publish and read the config. The integration surface is tiny — read all of it before writing app code:

php artisan vendor:publish --provider="Maxeckel\LivewireEditorJs\LivewireEditorJsServiceProvider"
# check --tag output; config tag is usually livewire-editorjs-config

Look at, in this order:

  1. config/livewire-editorjs.php — CDN/asset URLs, Editor.js tools, holder/property names, and the image upload folder setting added in v1.5.0.
  2. vendor/maxeckel/livewire-editorjs/resources/views/** — how the holder element is rendered and what props it takes.
  3. The component class in Maxeckel\LivewireEditorJs\... — which public properties exist ($value? $content?) and which events it emits. This tells you whether the installed version supports wire:model binding or only :value + an emitted event.

First use case — the canonical one. A posts.content JSON column edited in an admin form and rendered on a public page.

{{-- resources/views/livewire/post-editor.blade.php --}}
<div>
    <livewire:editor-js wire:model="content" />

    <button wire:click="save" class="btn">Save</button>
</div>
use Livewire\Component;

class PostEditor extends Component
{
    public Post $post;
    public array $content = ['blocks' => []];

    public function mount(Post $post): void
    {
        $this->post = $post;
        // hydrate null -> valid empty document, or Editor.js throws on init
        $this->content = $post->content ?? ['blocks' => []];
    }

    public function save(): void
    {
        $this->validate(['content.blocks' => 'required|array|min:1']);

        $this->post->update(['content' => $this->content]);
    }
}
// migration
$table->json('content')->nullable();

// model
protected $casts = ['content' => 'array'];

If wire:model is not wired up in your version, use the documented event pattern instead — :value="$content" in Blade plus a listener on whatever event the component emits:

protected $listeners = ['editorjs-changed' => 'syncContent'];

public function syncContent($payload): void
{
    $this->content = is_string($payload) ? json_decode($payload, true) : $payload;
}

Implementation Patterns

Understand the payload before you store it. Editor.js emits a document, not HTML:

{
  "time": 1712345678901,
  "blocks": [
    { "id": "abc123", "type": "header", "data": { "text": "Title", "level": 2 } },
    { "id": "def456", "type": "paragraph", "data": { "text": "Body with <b>inline</b> HTML." } }
  ],
  "version": "2.26.5"
}

Store the whole document so you can re-open it in the editor byte-for-byte; read blocks for rendering. Keep the raw copy — re-deriving it later from rendered HTML is lossy.

Choose one binding strategy and stick to it. There are two viable ones:

  • wire:model (live) — editor changes flow into the public property. Requires a non-deferred model, which means a request per change. Debounce heavily; Editor.js fires onChange on block mutations, not keystrokes.
  • wire:ignore + manual $wire.set() — the editor DOM is never morphed by Livewire; you push JSON in from the JS onChange callback. More code, dramatically more stable, and the only pattern that reliably survives arbitrary re-renders.
<div wire:ignore>
    <livewire:editor-js :value="$content" />
</div>

If you take the manual route, initialise inside the Livewire lifecycle hook rather than DOMContentLoaded, and debounce:

document.addEventListener('livewire:init', () => {
    Livewire.hook('morph.updated', ({ el }) => { /* re-init editors inside el */ });
});

Multiple editors on a page. Editor.js requires a unique holder id and unique wire:key; a hard-coded editorjs holder id in the package view is the usual reason a second editor silently fails to mount.

@foreach ($sections as $index => $section)
    <livewire:editor-js :key="'editor-'.$section['id']" :value="$section['content']" />
@endforeach

Custom tools. Enable block types you actually render. Keep the tool list in config so JS and Blade stay in sync — a header block in the DB and no header case in your renderer is the most common "content disappeared" bug. v1.5.0 ships Editor.js 2.26.5; if you build via npm, pin your @editorjs/* tool versions to match and point the package at your compiled bundle rather than CDN globals.

Image uploads. v1.5.0 adds the ability to specify a folder for saving images (PR #33). Read the published config and component to see whether your version provides the full upload endpoint or only the storage folder. Editor.js's Image tool still expects a JSON response in a fixed shape:

public function upload(Request $request)
{
    $request->validate(['image' => 'required|image|max:5120']);

    $path = $request->file('image')->store('editor', 'public');

    return response()->json([
        'success' => 1,
        'file' => ['url' => Storage::disk('public')->url($path)],
    ]);
}

Set the tool's config.endpoints.byFile to that route if the package does not already do so. Leave base64 mode off — it inflates the JSON column and kills query performance.

Rendering read-only. The package ships no renderer, so this is yours to own. Either a JSON→HTML converter (the editorjs-html family of packages) cached into a content_html column, or a small Blade component with a match on $block['type'] and a graceful fallback for unknown types:

@foreach ($post->content['blocks'] ?? [] as $block)
    <x-editor-block :block="$block" />
@endforeach

Cache the rendered HTML if the page is public-facing; re-render it whenever the document is saved.

Sanitize on write, not on read. Editor.js block data.text legitimately contains inline HTML (<b>, <i>, <a href>). Run the whole document through mews/purifier or a strip_tags allowlist before persisting, so every renderer downstream can trust it.

Test through Livewire, not the browser. Livewire::test(PostEditor::class)->set('content', $fixture)->call('save')->assertDatabaseHas(...) with a JSON fixture per tool type covers the round-trip without booting Editor.js.

Gotchas and Tips

Maintenance risk remains the headline. v1.5.0 adds Laravel 10 support and updates Editor.js, but it still does not mention Livewire 3 or Laravel 11. On Laravel 10 / Livewire 2, use the latest tag. On Livewire 3 / Laravel 11, budget for a fork and pin the fork commit in composer.json rather than tracking a branch.

Livewire morphing destroys the editor. Any re-render that touches the holder's parent recreates the DOM node and Editor.js detaches — you get an empty editor with content still in the database. Symptom in the console: "element with holder id not found." Fix: wire:ignore on the wrapper, wire:key on the component, and sync values out via JS rather than in via attributes.

wire:ignore and wire:model conflict. You cannot have both on the same element and expect updates. Pick the event/$wire.set() path once you go wire:ignore.

Deferred models silently lose the last edit. On Livewire 3, wire:model is deferred; a save button clicked right after typing persists the previous value. Use .live.debounce.500ms (or the v2 .defer semantics you actually tested against) and never mix styles in one form.

Nulls and empty documents. null passed to the JS data option throws on init; {"blocks":[]} passes and saves a blank post. Hydrate to ['blocks' => []] in mount() and validate content.blocks as required|array|min:1 server-side.

Double encoding. If you wire:model a JSON string and cast the column to array, you'll json_encode a string and store "\"{...}\"". Decide string vs array at the property boundary and assert it in a test.

Quotes in Blade attributes. Passing raw JSON through an HTML attribute breaks on embedded quotes. Use :value="$content" (property binding) or @js($content), never value="{{ $content }}".

Asset load order and wire:navigate. Editor.js tool scripts must be registered before initialisation; on soft navigation DOMContentLoaded never fires, so editors on subsequent pages stay dead. Hook livewire:navigated (or livewire:init + a morph hook) instead.

Every tool needs a renderer. Enabling table, embed, or raw in config without a matching Blade case means that content vanishes from the public page — with no error. Keep a default branch that logs unknown types, and add a test per enabled tool.

Blob URLs don't persist. Images uploaded into the editor before your endpoint is configured resolve to blob: URLs that die on reload. v1.5.0's folder setting helps with storage location, but verify the response shape exactly: Editor.js fails silently unless it receives { success: 1, file: { url: ... } }.

Dirty-check noise on array casts. Re-encoding can reorder keys and mark the model dirty on unchanged saves. Normalise before comparing (or compare json_encode of both sides) if you use isDirty().

Where to extend without forking. Config's tools array, the image upload folder setting added in v1.5.0, the publishable Blade view (resources/views/vendor/livewire-editorjs), and the emitted-event listener are the supported extension points. Anything else — holder ids, asset loading, full upload endpoint behavior — is either config or a fork.

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.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle