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.

Maintainers

Package info

github.com/sanderdewijs/livewire-cms-editor

pkg:composer/degrinthorst/livewire-cms-editor

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-29 08:47 UTC

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.md for 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.json is committed; CI uses npm 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.json with the equivalent minimumReleaseAge: "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.
  • .npmrc save-exact stops caret ranges from silently floating.
  • CI gates on npm audit for production deps, pins GitHub Actions to commit SHAs, and runs with least-privilege contents: 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.