tbmatuka/editorjs-bundle
Laravel bundle for the Editor.js block-style editor. Store, parse and render Editor.js JSON content, converting blocks into clean HTML for display. Simplifies integrating Editor.js with Laravel models and Blade views.
tbmatuka/editorjs-bundle is a Symfony bundle that wraps Editor.js v2. It does three things:
EditorjsType) that renders the Editor.js container and its assets.Editorjs value object wrapping the {time, blocks, version} JSON.It does not give you a Laravel package. There is no service provider, no Blade directive, no Eloquent cast. If you are on Laravel, treat the repo as a reference implementation for the server-side half (JSON model + block parsers + upload contract) and write the client half yourself — the JS is stock Editor.js either way.
First steps if you are actually in a Symfony app:
composer require tbmatuka/editorjs-bundle
# config/bundles.php
EditorjsBundle\EditorjsBundle::class => ['all' => true],
Copy/install the bundle's assets (bin/console assets:install), then include Editor.js and the bundle's form theme in your layout. Look first at:
src/Editorjs/Editorjs.php — the value object (getBlocks(), getHtml(), toJson()).src/Editorjs/EditorjsHtmlConverter.php — the JSON→HTML entry point.src/Editorjs/BlockParser/ — one parser per block type; this is where you extend.Resources/config/ — the tool registration YAML you will be editing constantly.First use case: swap a <textarea>/CKEditor field in an admin form for EditorjsType, persist the raw JSON, and render converted HTML on the front end.
// Entity
#[ORM\Column(type: 'json')]
private array $body = [];
// Form
$builder->add('body', EditorjsType::class, [
'tools' => ['header', 'list', 'image', 'delimiter'],
]);
Laravel equivalence, roughly: a text/json column, a Blade component mounting Editor.js, a POST /editorjs/upload endpoint, and a EditorjsToHtml converter class you own.
Store JSON, render HTML. Never store the converted HTML as the source of truth — you cannot round-trip it back into blocks for editing. Keep the raw JSON (Doctrine json column, MySQL JSON, or longText if you are on older MySQL), and convert on output:
$editorjs = new Editorjs($post->body); // array from the json column
$html = $converter->convert($editorjs->getBlocks());
If read performance matters, cache the HTML in a second column and invalidate on save. That is a two-line prePersist/observer hook.
Data transformers / casts. The bundle ships a transformer so the form binds an Editorjs object to a JSON column. In Laravel the equivalent is a custom cast:
protected $casts = ['body' => EditorjsCast::class];
// get: new Editorjs(json_decode($value, true));
// set: json_encode($value->toArray(), JSON_UNESCAPED_UNICODE);
Custom block parsers. Every block type you enable in the editor needs a matching server-side parser, or the block silently disappears (or throws, if the converter is in strict mode). In Symfony you implement EditorjsBlockParserInterface and tag the service:
class WarningBlockParser implements EditorjsBlockParserInterface
{
public function getSupportedBlockType(): string { return 'warning'; }
public function parse(array $blockData): string
{
$title = htmlspecialchars($blockData['title'] ?? '', ENT_QUOTES);
$msg = htmlspecialchars($blockData['message'] ?? '', ENT_QUOTES);
return "<div class=\"warning\"><strong>{$title}</strong><p>{$msg}</p></div>";
}
}
App\Editorjs\WarningBlockParser:
tags: ['editorjs.block_parser']
In Laravel this is just a match ($block['type']) in a converter class or a BlockParser registry keyed by type. Do not skip the whitelist — unknown block types are attacker-controlled keys.
Tool configuration is split across two places. The YAML config defines which tools the client gets and their JS options; the parser registry defines how they render. Adding table, embed, or code means: add the JS bundle to your layout, add the tool entry to the bundle config with its class and options, and add a parser. Miss one of the three and you get either a JS console error or a dropped block.
Upload endpoints. Image/file tools POST to a URL you configure; the expected response shape is Editor.js's standard:
{ "success": 1, "file": { "url": "https://cdn.example.com/abc.jpg" } }
The Symfony bundle leaves the controller to you. In Laravel:
Route::post('/editorjs/upload', [EditorjsUploadController::class, 'store'])
->middleware(['auth', 'can:write-posts']);
public function store(Request $request)
{
$request->validate(['image' => ['required', 'image', 'max:4096', 'mimes:jpg,png,webp']]);
$path = $request->file('image')->store('editorjs', 's3');
return response()->json([
'success' => 1,
'file' => ['url' => Storage::disk('s3')->url($path)],
]);
}
Pass csrfToken (or X-CSRF-TOKEN) plus endpoints.byFile in the tool config; both Symfony and Laravel will 419/403 the XHR otherwise.
Rendering. In Twig the bundle wires a form theme and you can output {{ post.body.html|raw }} via the value object. In Blade, <div class="prose">{!! $post->body_html !!}</div>.
Validation beyond the form type. The bundle validates shape loosely. Add your own rules: max block count, max total JSON size, allowed type values, and the per-block data constraints. In Laravel a FormRequest with a nested blocks.*.type in: rule covers most of it.
save()/render(); mixing a new tool with the bundle's bundled JS will break.symfony/form, symfony/twig-bridge, and the asset component. Do not attempt it — copy the Editorjs class and the block parsers (MIT licensed, small) and drop the Form/Twig plumbing.blocks[].data is user input: text (contains inline HTML from the inline toolbar), caption, items[], url, src, code. Store raw, escape/sanitize on convert, and whitelist URL schemes (http, https, mailto) — a javascript: in a link or image src is the classic Editor.js injection.blocks: [] for an untouched editor. Guard against rendering <p></p> wrappers and against NOT NULL violations.JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES when encoding; otherwise you get \uXXXX soup in the DB that is painful to debug. Doctrine's json type hands back an array, not the Editorjs object — wrap it at the boundary.data-* attribute or an inline script. Use json_encode with JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT (or html_attr escaping in Twig). A </script> inside a tool config string will break the page.success: 1 with no file.url renders a broken image with no client-side error. Log the raw request and response for the first integration.dd($post->body) / $editorjs->getBlocks()), then feed it into the converter in isolation. Most "missing content" reports are a parser that returns an empty string for a block type it does accept.npm i @editorjs/editorjs plus the individual tool packages, bundle with Vite, and mount the editor from a small Alpine component. That is less work than adapting the bundle's asset wiring.How can I help you explore Laravel packages today?