setono/editorjs-php
Setono Editor.js PHP is a PHP library for parsing, sanitizing, and rendering Editor.js JSON as HTML. It provides custom block handlers, validation, and extensible configuration, making it easy to safely display Editor.js content server-side with clean, controlled output in Laravel or any PHP applica...
Install with Composer:
composer require setono/editorjs-php
The package is a thin PHP SDK around the JSON that Editor.js produces. Editor.js saves output in the shape:
{
"time": 1710000000000,
"version": "2.28.2",
"blocks": [
{"id": "abc", "type": "paragraph", "data": {"text": "Hello <b>world</b>"}},
{"id": "def", "type": "header", "data": {"text": "Title", "level": 2}}
]
}
Look first at the src/ tree — it is small, and the layout tells you almost everything:
Block/ — one typed class per Editor.js block type (ParagraphBlock, HeaderBlock, ListBlock, ImageBlock, …).Parser/ — turns the decoded JSON array into a Block collection.Renderer/ — turns a Block back into HTML.Exception/ — what you will catch when the payload is malformed or contains an unknown block.Your first use case should be the canonical one: store the raw Editor.js JSON, render it to HTML server-side.
use Setono\EditorJS\Parser\Parser;
use Setono\EditorJS\Renderer\Renderer;
$payload = json_decode($request->getContent(), true);
$parser = new Parser();
$content = $parser->parse($payload); // block objects
$html = (new Renderer())->render($content);
// persist the original JSON, not the HTML, as the source of truth
$post->content = json_encode($payload);
Verify the exact constructor signatures against src/Parser and src/Renderer before writing a lot of code — the package is small and under-documented (42 stars), so src/ is the authoritative reference, not the README.
Parse on write, render on read. Treat the Editor.js JSON as the immutable source. Parse it once when the request comes in to validate that every block is understood, then persist. Render on demand (or cache the HTML). This keeps the door open to re-rendering with a fixed renderer when you upgrade the package or fix a template.
Two-phase validation. Use the parser as a validator in your form request / controller:
try {
$content = (new Parser())->parse($payload);
} catch (\Throwable $e) {
throw ValidationException::withMessages(['content' => 'Unsupported editor content.']);
}
This prevents storing JSON from block types the renderer cannot handle, which otherwise surfaces as a 500 on a public page weeks later.
Custom blocks via registration, not forking. For a block type the package does not ship (product embed, callout, table), implement your own block class following the same contract as the shipped ones in src/Block, then register it with the parser/renderer. Keep custom blocks in App\EditorJs\Block\...; the package is MIT-licensed but small enough that forking it is a maintenance trap.
Fallback block for the unknown. In practice you will meet table, embed, code, delimiter, or plugin-specific types. Register a RawBlock/fallback that either renders $data as-is or returns an empty string. Decide explicitly: fail loudly in dev, degrade gracefully in production.
Cache the rendered HTML, keyed by content hash.
$key = 'post:html:' . hash('xxh3', $post->content);
return Cache::rememberForever($key, fn () => (new Renderer())->render(
(new Parser())->parse(json_decode($post->content, true))
));
Parsing is cheap; HTML rendering in a loop is the part worth memoizing.
Service container binding. In Laravel, bind Parser and Renderer as singletons in a service provider so the block registry is configured in one place. In Symfony, the same object graph is a single services.yaml entry. Do not new them at every call site once you have custom blocks registered.
API / headless output. Expose GET /api/posts/{id}/content?format=html|json. Return the stored JSON for the editor to rehydrate; return rendered HTML for consumers. Keep the raw blocks array available — Editor.js needs it to re-open a document for editing.
Sanitize at render, not at save. If your editor allows HTML inside paragraph/header, save it verbatim (so editing round-trips) but pipe rendered output through HTMLPurifier in the renderer or the view. Saving sanitized HTML destroys content the user may re-edit.
{time, blocks, version}, not a bare array. Some helpers accept the whole envelope, others only blocks. Read the parser signature; passing the wrong level is the most common first-day error.type: "raw" or a plug-in block. Install a fallback block before you ship, not after.{"type":"paragraph","data":{"text":""}} for trailing blank lines. Filter empty paragraphs and empty headers before rendering, or you will get stray <p></p> margins throughout the page.list block's items can be strings or objects ({content, items: [...]}) depending on version and whether the list is a checklist. Confirm the renderer recurses; otherwise flatten in your own block class.data.file.url depends entirely on what the uploader returned — a S3 key, a relative path, or a full URL. Normalize to an absolute URL in your block subclass or in a Twig/Blade template.raw and paragraph are XSS vectors. Editor.js does not sanitize; it only encodes what is typed. Sanitize on output. This is the single highest-severity pitfall in this package.composer.json, read the CHANGELOG/git tags, and check the composer.json require block for the PHP constraint (8.x) and any ext-* dependencies. Do not rely on the README being current.dd(json_decode($post->content, true)) first, then dd($content->getBlocks()) after parsing. Most bugs are at the boundary between decoded array and block object, not inside the renderer.How can I help you explore Laravel packages today?