athphane/filament-editorjs
An Editor.js-powered rich text field for Filament and Laravel admin panels. Create block-based content with headers, lists, images, and other tools, store it as structured JSON, and render it cleanly on the frontend.
Install the package.
composer require athphane/filament-editorjs
Because this is a small, single-maintainer package, also confirm the supported Filament version in composer.json before committing to it — the package targets a specific Filament major (v3 at time of writing) and does not advertise wider compatibility.
Publish assets if the package ships any.
php artisan vendor:publish --tag="filament-editorjs-assets"
php artisan vendor:publish --tag="filament-editorjs-config"
If those tags don't exist, run php artisan vendor:publish interactively and pick the athphane/filament-editorjs provider entry. The published config (if present) is where you register custom Editor.js tools and upload disks.
Look at the source first, not the README. With 18 stars and no dependents, the README is likely thin. Read in this order:
src/ — the Filament field class (extends Filament\Forms\Components\Field). This tells you the exact method names for configuring tools, profiles, and height.resources/views/ — the Blade view wrapping the Editor.js mount point. This tells you which JS/CSS the package loads and whether it uses @vite, a published asset, or a CDN.resources/js/ — the Alpine/Livewire glue. This is where state syncing happens and where most bugs will live.config/ — upload disk, tool defaults.First use case — replace a RichEditor in a Resource form:
use Athphane\FilamentEditorjs\Forms\Components\EditorJs;
public static function form(Form $form): Form
{
return $form->schema([
EditorJs::make('content')
->label('Body')
->required(),
]);
}
Pair it with a json or longText column (see Gotchas) and you have a working block editor in one field declaration.
Verify the round trip before building further. Create a record through the form, inspect the raw DB value, and confirm it is Editor.js's { time, blocks, version } JSON — not HTML. That single check determines your entire frontend rendering strategy.
Store JSON, render server-side or client-side — pick one and be consistent.
Editor.js's canonical output is a JSON document:
{ "time": 1710000000000, "blocks": [ { "type": "paragraph", "data": { "text": "…" } } ], "version": "2.28.0" }
The field will hand you that string. Two viable patterns:
Server-side rendering (recommended for SEO): decode in the model and render blocks with a Blade partial, or pipe through a PHP/JS Editor.js-to-HTML renderer.
// app/Models/Post.php
protected function casts(): array
{
return ['content' => 'array'];
}
@foreach ($post->content['blocks'] ?? [] as $block)
@switch($block['type'])
@case('paragraph') <p>{!! $block['data']['text'] !!}</p> @break
@case('header') <h{{ $block['data']['level'] }}>{!! $block['data']['text'] !!}</h{{ $block['data']['level'] }}> @break
@case('list') <ul>@foreach ($block['data']['items'] as $i) <li>{!! $i !!}</li> @endforeach</ul> @break
@case('image') <img src="{{ $block['data']['file']['url'] }}" alt=""> @break
@endswitch
@endforeach
The cast is the important part: it makes the field's JSON survive Livewire hydration/dehydration cleanly and gives you an array without json_decode calls scattered everywhere.
Client-side rendering: ship the same Editor.js bundle to the public site and use editor.save() → render(), or a library like editorjs-html. This keeps parity with the editor's output but costs you a JS bundle on the frontend and does nothing for crawlers.
Custom tools. Most real projects need more than paragraph/header. Register tools in the published config (or via a fluent ->tools([...]) method if the field exposes one) pointing at your tool's JS class name and any config:
'tools' => [
'image' => [
'class' => 'ImageTool',
'config' => ['endpoints' => ['byFile' => route('editorjs.upload')]],
],
'code' => \RawCodeTool::class,
],
Define the tool classes in a small resources/js/editorjs.js and load them through Filament's asset registration (FilamentAsset::register([...]) in a service provider) rather than editing the package's view — that keeps upgrades clean.
Uploads. Editor.js's ImageTool expects an HTTP endpoint returning { success: 1, file: { url: "..." } }. Wire it to a dedicated route with its own controller and Storage::disk(config('filament-editorjs.disk', 'public'))->put(...). Do not reuse Filament's FileUpload component internals; the contracts are unrelated.
Reactive / dependent forms. If the field sits next to something that changes its content (e.g. a template selector), use ->afterStateUpdated(fn (Set $set, $state) => $set('content', $state)) and make sure the field is ->reactive(). The Alpine component must re-mount when Livewire replaces the DOM — if the editor goes blank after a $wire.set, that's the root cause.
Multi-locale. Wrap per-locale instances in a Tabs/Grid and suffix the state path (content.en, content.fr). The package does not handle translations itself; it is one field, one JSON blob.
Validation. Add a custom rule that asserts the JSON decodes and each block has a type and data key. Editor.js will happily save an empty blocks: [] array, so ->required() alone will not stop blank submissions.
VARCHAR(255) instantly. Use json or longText in the migration. json gives you validation at the DB level; longText is more forgiving across MySQL/MariaDB/SQLite.->required() does not work as you expect. The field's state is a JSON string that is always present ({"blocks":[]}), so required will pass on an empty editor. Validate the block count instead.data.text fields contain the raw HTML Editor.js emitted. Sanitize with a purifier (e.g. mews/purifier) before {!! !!} output. This is the single most common security bug in Editor.js integrations.php artisan filament:assets after install and after every composer update, and check php artisan filament:about (or the browser network tab) for the bundle.->reactive() chains where possible, use wire:ignore in the package view (if it isn't already there, add it in a published override), and explicitly re-init on livewire:navigated.->rules() location, getStatePath(), Field namespace). Check composer.json constraints before assuming the README snippet applies; if you're on the other major, expect to fork or pin.file on image blocks. If ImageTool's upload response shape is off by one key (file.url nested vs flat), the block saves but renders nothing on the frontend. Log the actual response once, early.AppServiceProvider::boot(), dump() the field's dehydrated state with ->dehydrateStateUsing(fn ($state) => tap($state, fn ($s) => logger()->debug($s))) to see exactly what Filament is about to persist. Half of all "content disappeared" reports resolve here.vendor:publish --tag=views) for wire:ignore/selector fixes, register tools via config rather than editing the package's JS, and register assets through a dedicated service provider. With a package this small and single-maintained, keeping your changes in app/ and resources/ is what makes composer update survivable.resources/js/ end to end and pinning the version in composer.json ("athphane/filament-editorjs": "1.*" or an exact tag) rather than floating on dev-main.How can I help you explore Laravel packages today?