lilac-wysiwyg/lilac
Lilac is a modern WYSIWYG editor for Laravel applications. It offers a simple Blade component, clean HTML output, image upload support, and configurable toolbars, making it easy to add rich text editing to your app.
Lilac is a small, single-purpose package: a WYSIWYG editor written in Qwik, shipped as a prebuilt JS/CSS bundle (the UMD build at dist/lilac.umd.js, exposing a window.Lilac global) plus a thin Composer/Laravel wrapper so you can drop a rich-text field into an existing form without wiring up a JS build step yourself. Treat it as a UI component, not a CMS — you still own persistence, sanitization, and rendering.
Minimal path:
Install, and pin to 0.7.1 or later.
composer require lilac-wysiwyg/lilac:0.7.1
0.7.0 is broken — it shipped without the UMD bundle, so the editor simply never appears. If your lockfile already has 0.7.0, run composer update lilac-wysiwyg/lilac before you debug anything else. Because the package is pre-1.0 with 15 stars and no dependents, pin explicitly and read the changelog before every bump.
Confirm the bundle is actually on disk. This is the one check worth doing immediately after install, because it's exactly what regressed in 0.7.0:
ls -l vendor/lilac-wysiwyg/lilac/dist/
# expect lilac.umd.js (+ css); if empty, you have the wrong version
Publish what it gives you.
php artisan vendor:publish # lists the real tags — do this first
php artisan vendor:publish --tag=lilac-config
php artisan vendor:publish --tag=lilac-assets
Look here first, in this order:
vendor/lilac-wysiwyg/lilac/README.md — the only authoritative API reference for a package this size.vendor/lilac-wysiwyg/lilac/dist/ — the UMD file name and CSS. If you're loading it manually, this is the exact path you reference.vendor/lilac-wysiwyg/lilac/resources/views/ — the Blade component/directive names and props.vendor/lilac-wysiwyg/lilac/config/lilac.php — toolbar, height, upload disk, asset source (CDN vs published).vendor/lilac-wysiwyg/lilac/src/ — usually 2–4 classes; skim them so you know what's actually happening.window.Lilac API and toolbar behaviour without grepping minified JS.First use case — swap a <textarea> in an admin CRUD. The editor enhances an existing textarea rather than replacing your form handling, so the diff is tiny:
{{-- before --}}
<textarea name="body" class="form-control">{{ old('body', $post->body) }}</textarea>
{{-- after --}}
<x-lilac::editor name="body" :value="old('body', $post->body)" />
Confirm the exact component name from the published views — the Blade side is thin and has historically been the least stable part.
Include the assets once, in your layout, not per-view:
@lilacStyles
...
@lilacScripts
If no directive is registered, load the UMD build yourself and init explicitly:
<script src="{{ asset('vendor/lilac/lilac.umd.js') }}"></script>
<script>window.Lilac?.init(document.querySelectorAll('[data-lilac]'));</script>
Prove the round trip with one request: save, redirect, re-open the edit form with the stored HTML, confirm the toolbar reflects formatting (bold stays bold, lists stay lists). If that works, the integration is done.
Wrap it in your own Blade component. Don't scatter <x-lilac::editor> through 30 views. Make one resources/views/components/rich-text.blade.php that owns the label, error state, help text, and Lilac invocation. When the package's API shifts (likely — it's 0.7.x and the 0.7.0 → 0.7.1 patch was a packaging bug, not a feature), you edit one file.
{{-- resources/views/components/rich-text.blade.php --}}
@props(['name', 'label' => null, 'value' => '', 'height' => 320])
<div class="mb-4">
@if($label)<label for="{{ $name }}" class="block font-medium">{{ $label }}</label>@endif
<x-lilac::editor :name="$name" :value="old($name, $value)" :height="$height" />
@error($name)<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
</div>
Initialise through one JS entry point. Because the public surface is the window.Lilac UMD global rather than a PHP class, keep all init in a single inline script or Alpine component and put it behind a helper:
window.mountLilac = (root = document) => {
root.querySelectorAll('[data-lilac]').forEach(el => {
if (el.dataset.lilacReady) return; // guard against double init
el.dataset.lilacReady = '1';
window.Lilac?.init(el);
});
};
document.addEventListener('DOMContentLoaded', () => window.mountLilac());
When you move to a Livewire/Turbo page-swap world you re-call mountLilac() from the navigation event and never touch the package internals.
Normalize on the way in, sanitize on the way out. Two separate concerns, two separate places:
<p><br></p> or <p></p> when the user types nothing, which defeats required validation and pollutes your DB with junk. Normalize in a form request's prepareForValidation():
protected function prepareForValidation(): void
{
$this->merge([
'body' => trim(strip_tags($this->body ?? '', '<img><iframe>')) === ''
? null
: $this->body,
]);
}
{!! $post->body !!} raw. Run it through HTMLPurifier (or mews/purifier) with an explicit allowlist at render time, or sanitize once on save and store the clean version. Sanitizing on save is cheaper; sanitizing on render is safer against config drift. Pick one and be consistent.Validation rule for a rich-text field:
'body' => ['nullable', 'string', 'max:65000', new SanitizedHtml],
Keep the max generous (HTML is verbose) but bounded — without it a paste of a whole document becomes a multi-megabyte row and a slow page.
Livewire / Alpine integration. Livewire's DOM morphing will fight a JS-initialized editor. The reliable pattern:
<div wire:ignore>
<x-lilac::editor name="body" :value="$body" wire:model.defer="body" />
</div>
wire:ignore prevents morphing from destroying the editor; you then sync the editor's HTML back to the component with an explicit $wire.set('body', html) on the editor's change/input event, or by reading the underlying textarea on submit. Don't rely on wire:model alone to capture keystrokes from a contenteditable. Then call mountLilac() inside a livewire:navigated listener, because a freshly morphed DOM has an uninitialised editor again.
Image uploads. If Lilac supports inline images, point it at a dedicated disk and a controller action rather than letting it base64-embed:
public (or S3), path = uploads/posts/{id}.php artisan storage:link or images 404 silently while the editor looks broken.intervention/image); a 6 MB phone photo in a blog post is the single most common complaint after this integration.image|mimes:jpeg,png,webp|max:5120. The editor's client-side checks are cosmetic.Config-driven toolbar presets. Publish the config and define two profiles — basic (bold/italic/link/lists) for comments or short bios, full (headings, images, tables, code) for long-form. Select per field via a component prop so you're not maintaining parallel editor builds.
Vendor the UMD build rather than trusting npm/CDN parity. The prepublishOnly hook that packs dist/lilac.umd.js is an npm lifecycle script; it says nothing about what a Composer archive or a CDN mirror contains. If you load from a CDN, verify the file at that exact URL for your version — or, simpler, publish assets locally and reference asset('vendor/lilac/lilac.umd.js').
Rendering outside the browser. For excerpts, RSS, og:description, or search indexes, derive a plain-text version on save and store it in a sibling column (body_excerpt). Regex-stripping tags at render time produces soup and kills list/heading context. One Str::limit(strip_tags($html), 160) in a model observer is enough.
Feature tests. Don't assert on editor markup — it's JS-generated, Qwik-hydrated, and version-dependent. Assert the contract: $response->assertSee('name="body"', false), post HTML directly to the endpoint, and assert the stored, sanitized value. Add one browser test (Dusk/Pest browser) that types into the editor and submits, because that's the part PHP tests can't see.
0.7.0 shipped without dist/lilac.umd.js — the editor does not exist on that version. Symptom is a blank field or a 404 on the script, with no PHP-side error at all. First move after any bump: ls vendor/lilac-wysiwyg/lilac/dist/. 0.7.1 moves bundle generation into prepublishOnly, after the TypeScript build, so the artifact exists at pack time — but that only guarantees the npm tarball. Verify the Composer-installed copy, not the release page.window.Lilac is what you integrate against; the Laravel package is a delivery mechanism for views, config, and assets. Read dist/ and the playground before assuming a Blade prop exists.DOMContentLoaded can fire before the editor is interactive, and a chunked lazy load can beat your init call. Guard your init with an existence check (window.Lilac?.init) and, if you see a flash of plain textarea, init from the library's own ready hook rather than from DOMContentLoaded.qwikloader.js-style assets and a permanently inert editor. Confirm with one look at the network tab on a bare Blade page.src/ and dist/. It will be short, and it's worth more than guessing at directives.POST <script> to your endpoint with curl. A WYSIWYG package does not sanitize for you. HTMLPurifier with a narrow allowlist is non-negotiable the moment non-admin users can post.@tailwind base resets h1–h6, ul, ol, and blockquote to unstyled. Either scope the editor's CSS (.lilac-content h1 { ... }) or wrap the rendered output in a container with explicit typography rules. Symptom: "the editor looks right but the published page has no bullets or heading sizes."<head>, or assets emitted before the field) fails silently with a console error you'll only notice in devtools. Put scripts at the end of body, or wrap init in DOMContentLoaded.mountLilac() helper above from a turbo:load / livewire:navigated listener rather than hoping the package handles it.<p><br></p> is not empty. Covered above, but it bites twice: required validation passes on a blank editor, and {{ $post->body ? 'has content' : 'empty' }} always says "has content."dist/lilac.umd.js present on disk for your installed version? (1) is the script in the page source and returning 200? (2) is there exactly one element matching the selector? (3) console errors / loader 404s? (4) does it work on a bare Blade view outside your layout? That last step isolates CSS/JS conflicts in about two minutes.window.Lilac.init as the main knobs, with no plugin API. If you need custom buttons, custom paste handling, or a specific HTML schema, fork or subclass rather than patching vendor/. Wrap the editor in your own Blade component now so a future swap to TipTap/Trix/Editor.js is a one-file change instead of a grep-and-replace.0.7.1 for now, commit the lockfile, and read the diff before bumping — a packaging regression in a patch release is exactly the failure mode a ^0.7 constraint will not protect you from.How can I help you explore Laravel packages today?