codex-team/editor.js
Editor.js is a modern block-style content editor that produces clean, structured JSON instead of raw HTML. Its extensible block and plugin system makes it easy to build flexible writing experiences and store content that’s simple to validate, transform, and render anywhere.
Editor.js is a JavaScript package, not a Composer package. There is nothing to composer require. Your Laravel integration is three separate concerns: install the JS via npm/Vite, store the JSON in a column, and render it yourself server-side.
1. Install the core plus the tools you actually want
npm i @editorjs/editorjs @editorjs/header @editorjs/list @editorjs/image \
@editorjs/quote @editorjs/delimiter @editorjs/code @editorjs/table
Every block type is a separate package. Start with paragraph (built in), header, list, and image — that covers 90% of article/blog bodies. Add more only when an editor asks for them; each one costs bundle size.
2. Migration
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->json('body'); // MySQL 5.7+/Postgres jsonb
$table->longText('body_text')->nullable(); // search/excerpt derivative
$table->timestamps();
});
3. Eloquent cast (do this before you write any rendering code)
protected $casts = ['body' => 'array'];
4. Blade + Vite mount point
{{-- resources/views/admin/posts/edit.blade.php --}}
<form method="POST" action="{{ route('admin.posts.update', $post) }}" id="post-form">
@csrf @method('PUT')
<input type="hidden" name="body" id="body-input"
value="{{ json_encode(old('body', $post->body)) }}">
<div id="editorjs" class="prose"></div>
<button type="submit">Save</button>
</form>
@vite(['resources/js/editor.js'])
// resources/js/editor.js
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
const input = document.getElementById('body-input');
const editor = new EditorJS({
holder: 'editorjs',
data: input.value ? JSON.parse(input.value) : undefined,
tools: { header: Header, list: List },
onChange: debounce(async () => {
const data = await editor.save();
input.value = JSON.stringify(data); // input is always current
}, 400),
});
First use case to ship: replace a textarea on a "post body" field. That is the whole loop — save() → hidden input → FormRequest → JSON column → Blade partial render. Do that end-to-end before touching custom tools.
Where to look first: the shape of editor.save() output. It is { time, blocks: [{ id, type, data }], version }. Everything you validate, store, and render hangs off that array. Read the per-tool docs for the data shape of each type you enable — they are not uniform.
Editor.js does not sanitize, does not validate, and does not know your schema. Treat the posted JSON as fully hostile input.
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'array'],
'body.blocks' => ['required', 'array', 'max:500'],
'body.blocks.*.type' => ['required', 'string', Rule::in($this->allowedTools())],
'body.blocks.*.data' => ['required', 'array'],
];
}
private function allowedTools(): array
{
return ['paragraph', 'header', 'list', 'image', 'quote', 'code', 'delimiter'];
}
}
A dedicated ValidEditorJsDocument rule is worth the 40 lines once you have more than one content model. Keep an allow-list of types but be tolerant of unknown types on read — see Gotchas.
This is the single most valuable pattern for a Laravel dev. Do not render blocks in JS — you'll lose SEO, RSS, emails, and OpenGraph descriptions.
// resources/views/components/blocks.blade.php (or a <x-blocks :blocks="$post->body['blocks']" />)
@foreach ($blocks as $block)
@includeIf("blocks.{$block['type']}", ['data' => $block['data'] ?? []])
@endforeach
{{-- resources/views/blocks/paragraph.blade.php --}}
<p>{!! $data['text'] ?? '' !!}</p> {{-- inline HTML, see sanitization below --}}
{{-- resources/views/blocks/header.blade.php --}}
@php($tag = 'h' . min(6, max(1, (int) ($data['level'] ?? 2))))
<{{ $tag }}>{!! $data['text'] ?? '' !!}</{{ $tag }}>
@includeIf means an unknown or retired block type silently renders nothing instead of throwing — which is exactly what you want after you remove a plugin.
Wrap the whole thing in a service so you're not repeating yourself:
class EditorJsDocument
{
public function __construct(private array $doc) {}
public static function fromArray(?array $doc): self { return new self($doc ?? []); }
public function blocks(): array { return $this->doc['blocks'] ?? []; }
public function toHtml(): string
{
return view('components.blocks', ['blocks' => $this->blocks()])->render();
}
public function toPlainText(): string
{
return collect($this->blocks())
->map(fn ($b) => strip_tags($b['data']['text'] ?? $b['data']['caption'] ?? ''))
->filter()->implode("\n\n");
}
}
Then in an observer/saving hook, populate body_text with toPlainText() so you get a searchable column, a meta description, and an excerpt for free. JSON columns are not full-text searchable in MySQL — this derivative column is not optional if search matters.
Inline formatting (<b>, <i>, <a>, <mark>, <code>) arrives as raw HTML inside data.text, and the raw tool is literally arbitrary HTML. Render with {!! !!} only after sanitizing:
use Mews\Purifier\Facades\Purifier;
$clean = Purifier::clean($data['text'], 'editorjs'); // config: allow only b,i,a,mark,code,br
Configure HTMLPurifier with HTML.SafeIframe off, a protocol whitelist on URI.AllowedSchemes, and rel="nofollow noopener" on links. Do this at write time (store sanitized) and render time (defense in depth, catches pre-existing rows).
The image tool POSTs a file and expects a specific response shape.
Route::post('/editor/images', [EditorUploadController::class, 'store'])
->middleware(['auth', 'throttle:30,1']);
public function store(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)],
]);
}
image: {
class: ImageTool,
config: {
endpoints: { byFile: '/editor/images' },
additionalRequestHeaders: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
},
}
Return a root-relative or absolute URL, not a storage path. If you move to S3, Storage::url() keeps the tool working.
wire:ignore on the holder div or Livewire's DOM diff will wipe the editor on every render. Sync via editor.save() → $wire.set('body', data) in onChange. Don't re-instantiate the editor in updated() hooks.data, and post it back as body (an object) — Laravel's array validation rules apply unchanged.x-init="initEditor()", x-on:submit), but the editor instance should live outside Alpine's reactive proxy to avoid deep-watching a large object graph.onChange fires on every keystroke. Debounce 400–800ms, PATCH to a draft endpoint, and show a "Saved" indicator. Store drafts in the same column with a status enum; don't create a separate drafts table.
Fixtures are just JSON — that makes this cheap to test:
it('renders each supported block type', function (string $type) {
$post = Post::factory()->create(['body' => ['blocks' => [
['id' => 'x', 'type' => $type, 'data' => fixture("blocks/{$type}.json")],
]]]);
$this->get(route('posts.show', $post))->assertOk()->assertSee('<', false);
})->with(['paragraph', 'header', 'list', 'quote']);
Snapshot the rendered HTML per block type. When you upgrade a tool package and its data shape shifts, these tests tell you immediately instead of after a deploy.
Editor.js is browser-only. No SSR, no Node rendering, no PHP renderer. If you're on Inertia with SSR or doing prerendering, guard the import (if (typeof window !== 'undefined') or dynamic import()), and always render the saved HTML server-side for crawlers.
Empty documents save as { blocks: [] }. required|array passes, then your view renders nothing and the page looks broken. Add an explicit rule: 'body.blocks' => ['required', 'array', 'min:1'] plus a function ($attr, $value, $fail) that rejects all-empty paragraphs.
Don't reinitialize on re-render. Double-init throws element already has an editorjs instance. Always keep a reference and call editor.destroy() before recreating (e.g., when switching between create/edit routes in an SPA). Guard with await editor.isReady.
The holder must exist when you call new EditorJS(). With Vite module scripts and deferred DOM, that ordering is usually fine; with Livewire/Alpine toggles it is not. Init inside x-init or DOMContentLoaded.
Block ids are client-generated and not trustworthy. They're nanoid-ish, client-controlled, and can collide after a copy/paste. Never use them as a primary key or as the anchor for a stable URL unless you regenerate them server-side.
version is the Editor.js version, not your schema version. Add your own schema_version to the stored document (or a sibling column) so you can migrate block data later. This costs one line now and saves a painful backfill later.
Be permissive on read, strict on write. If you remove the table tool from the toolbar, existing documents still contain table blocks. Validate the allow-list on incoming writes, but let the renderer skip unknown types. Rejecting stored-but-unrenderable blocks during a save will corrupt existing posts.
raw tool = stored XSS by design. Either disable it entirely, or sanitize its html field on save with a strict allow-list. Same for embed (iframe src must be protocol- and host-whitelisted) and link (block javascript: hrefs).
CSRF on upload endpoints. Tools use fetch/XHR with cookies for same-origin requests, so web middleware + token header works — but if you put the upload route behind api middleware you'll get 419s. Keep it in web and pass additionalRequestHeaders.
Column type matters. MySQL TEXT caps at 64KB — a long article with tables will silently truncate or error. Use JSON (or LONGTEXT if you want to bypass MySQL's JSON validation). Character set must be utf8mb4 for emoji.
No HTML output at all. There's no toHtml() in the package, and third-party converters lag behind tool versions. Partial-per-block is more work up front and dramatically less work forever — you control the markup, the classes, and the a11y attributes.
Bundle size creep. Each tool is a package with its own deps. Ten tools can double your admin bundle. Dynamic-import the editor module on admin routes only (@vite per-layout) so public pages never pay for it.
CSP friction. Tools inject inline <style> tags. If you run a strict style-src, you'll need 'unsafe-inline' for the admin route or a nonce strategy — a common reason "the editor renders unstyled in production but fine locally."
Emails and RSS. Mail clients choke on Editor.js-driven markup. Write a separate, deliberately boring mail view (paragraphs and headings only, inline styles), not a reuse of the site partials.
Importing legacy HTML. There is no reliable reverse converter. For a migration, parse with DOMDocument, map <p>/<h*>/<ul>/<img> to blocks, and accept that you'll drop the weird stuff — one Artisan command, run once, logged. Don't attempt round-tripping.
Extension point worth knowing: editor.save() resolves with the data, but onChange gives you the same thing plus the triggering event. Use onChange for autosave, save() for explicit submits, and never mix the two in one handler — you'll race yourself.
How can I help you explore Laravel packages today?