degrinthorst / livewire-cms-editor
A WordPress-classic-style rich text editor for Laravel Livewire, with first-class image insertion and properties, built on TipTap and Spatie MediaLibrary.
Package info
github.com/sanderdewijs/livewire-cms-editor
pkg:composer/degrinthorst/livewire-cms-editor
Requires
- php: ^8.2
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- livewire/livewire: ^4.0
- spatie/laravel-medialibrary: ^11.0
- ueberdosis/tiptap-php: ^2.1
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
Suggests
- mews/purifier: Recommended for hardened HTML sanitization of rendered output (ADR-008).
This package is auto-updated.
Last update: 2026-06-29 08:48:55 UTC
README
A WordPress-classic-style rich text editor for Laravel Livewire 4, with first-class image insertion and per-placement image properties (width/height/class/style), built on TipTap and Spatie MediaLibrary.
This is a v0 skeleton. It contains a coherent, opinionated architecture and working-shaped code, but is not yet a published, fully-tested package. See
docs/ARCHITECTURE.mdfor every design decision and the open maintenance risks.
Why
Editors like flux:editor are fine but too thin for clients coming from
WordPress. The missing feature is inserting an image into the body and setting
its display properties. Here that image is a real TipTap node backed by a
MediaLibrary record, so it's reusable across articles via a built-in picker.
Requirements
- PHP 8.2+
- Laravel 11/12, Livewire 4
- Spatie MediaLibrary 11
ueberdosis/tiptap-php(server-side rendering)
Install
composer require degrinthorst/livewire-cms-editor php artisan cms-editor:install
The cms-editor:install command publishes the config, optionally publishes the
pre-built assets, asks which model and column(s) hold the content, writes
those choices to your .env, and scaffolds a migration for the column(s). It
never edits your model — it prints the trait/interface/cast you add yourself.
Prefer to do it by hand? The manual equivalents are:
php artisan vendor:publish --tag=cms-editor-config
php artisan vendor:publish --tag=cms-editor-assets # pre-built JS into public/vendor/cms-editor
Columns: JSON vs cached HTML (ADR-003)
The package is column-agnostic for the JSON — the editor pushes ProseMirror
JSON through wire:model and your form persists it to your own column. The only
package-managed column is the optional rendered-HTML cache:
// config/cms-editor.php (driven by .env) 'columns' => [ 'json' => env('CMS_EDITOR_JSON_COLUMN', 'body'), // source of truth, cast to array 'html' => env('CMS_EDITOR_HTML_COLUMN'), // null = render on the fly ],
When columns.html is set, add the SyncsEditorHtml trait to your model and the
HTML column is re-rendered from the JSON on every save (image src is re-resolved
from MediaLibrary at that moment — a renamed/replaced image refreshes on the
next save).
Front-end — either load the pre-built bundle, or import the source in your app.js.
Pre-built bundle. dist/cms-editor.js is an ES module, so it must be
loaded with type="module" (a plain <script> throws on its export and the
editor never registers). It self-registers on window.Alpine / alpine:init:
{{-- in your layout <head> --}} <link rel="stylesheet" href="{{ asset('vendor/cms-editor/cms-editor.css') }}"> <script type="module" src="{{ asset('vendor/cms-editor/cms-editor.js') }}"></script>
(vendor:publish --tag=cms-editor-assets copies both the JS and CSS into
public/vendor/cms-editor.)
Or import the source in your bundled app.js:
import { registerCmsEditor } from '@degrinthorst/livewire-cms-editor' document.addEventListener('alpine:init', () => registerCmsEditor(window.Alpine))
Include the base styles (optional): resources/css/cms-editor.css.
Prepare your model
use Degrinthorst\CmsEditor\Concerns\AdoptsEditorMedia; use Degrinthorst\CmsEditor\Concerns\InteractsWithEditorMedia; use Degrinthorst\CmsEditor\Contracts\HasEditorMedia; use Spatie\MediaLibrary\InteractsWithMedia; class Article extends Model implements HasEditorMedia { use InteractsWithMedia; use InteractsWithEditorMedia; use AdoptsEditorMedia; // claims inserted images on save (recommended) public function registerMediaCollections(): void { $this->registerEditorMediaCollection(); } protected $casts = ['body' => 'array']; // ProseMirror JSON (ADR-003) }
AdoptsEditorMedia makes the model own the images it references on save (so they
cascade-delete with it). It's optional — without it, images stay on the shared
upload bucket (ADR-009). Skip it only if you use upload_binding=model.
Want the cached-HTML column? Also use Degrinthorst\CmsEditor\Concerns\SyncsEditorHtml;
and set CMS_EDITOR_HTML_COLUMN (the install command does both for you).
Set article_model in config/cms-editor.php (or CMS_EDITOR_ARTICLE_MODEL in
.env).
Use it
{{-- inside a Livewire form component with a public array $body --}} <x-cms-editor wire:model="body" :model="$article ?? null" />
The same tag works for create and edit: pass the model when you have one,
null when you don't. Uploads on a new article attach to the package upload
bucket and are adopted onto the article on save (ADR-009) — no draft record
needed.
Render the stored document to HTML on the front-end:
app(\Degrinthorst\CmsEditor\Support\ContentRenderer::class)->toHtml($article->body);
How it fits together
| Concern | Where |
|---|---|
| Editor engine | TipTap (resources/js/editor.js) inside wire:ignore |
| Image node | resources/js/extensions/MediaImage.js + src/Extensions/MediaImage.php |
| Media picker | src/Livewire/MediaPicker.php (restrained to model + collection) |
| Storage | ProseMirror JSON, rendered via ContentRenderer |
| Safety | ContentSanitizer allowlist (swap for HTMLPurifier in prod) |
| Livewire sync | one-way bridge, see ADR-006 |
Hardening the sanitizer
The bundled ContentSanitizer is dependency-free and deliberately simple. For
production, bind HTMLPurifier instead:
$this->app->bind( \Degrinthorst\CmsEditor\Support\ContentSanitizer::class, YourPurifierSanitizer::class, );
Distribution & maintenance
The pre-built dist/cms-editor.js is committed to the repo so composer require
works without a Node toolchain (Packagist serves the git-tag archive). Keep it
fresh: run npm run build and commit the result before tagging a release — CI
rebuilds and fails if the committed bundle drifts from the source.
Supply-chain hardening:
package-lock.jsonis committed; CI usesnpm ci(verifies integrity hashes).- Dependabot (
.github/dependabot.yml) updates npm, Composer and GitHub Actions with a 7-day cooldown (cooldown.default-days: 7) — no dependency version younger than a week is adopted (the window in which most malicious releases get caught and yanked). This is native to GitHub; no app to install. - A
renovate.jsonwith the equivalentminimumReleaseAge: "7 days"is included as an alternative. Don't run both at once (duplicate PRs) — pick one; enable the Renovate GitHub app only if you disable Dependabot. .npmrcsave-exactstops caret ranges from silently floating.- CI gates on
npm auditfor production deps, pins GitHub Actions to commit SHAs, and runs with least-privilegecontents: read.
Image properties
Select an inserted image and a contextual panel appears above the editor to set
its per-placement width, height, alignment (none/left/center/right) and a
freeform style (ADR-004). Alignment uses WordPress-familiar classes
(alignleft/aligncenter/alignright) shipped in cms-editor.css; include
those styles on the front-end too. Intrinsic data (alt/caption) is edited in the
picker, not here. The style field is filtered by the render-time allowlist.
Pruning unused images
Editor images that no document references any more — un-inserted bucket uploads and de-referenced host media — are cleaned up by:
php artisan cms-editor:prune-orphans --dry-run # preview php artisan cms-editor:prune-orphans --ttl=7 --force # delete, sparing bucket uploads < 7 days
Schedule it (e.g. daily with --ttl=7 --force). It scans the configured model's
JSON column for live mediaIds; configure extra sources via prune.sources.
Roadmap
- Livewire 3 compat layer.
- JSON↔HTML render snapshot tests (Onderhoudsrisico #3).
See docs/ARCHITECTURE.md for the full reasoning.