locaine/lcn-wysihtml5-bundle
LCN WysiHTML5 Bundle integrates the wysihtml5 rich-text editor into Laravel/PHP applications, providing a lightweight WYSIWYG field for content forms. It packages editor assets and helpers to simplify setup, configuration, and use in views.
Start with the framework check — it determines everything else. locaine/lcn-wysihtml5-bundle is a Symfony2 bundle, not a Laravel package. It ships a Symfony\Component\Form\AbstractType subclass, a DI container extension, Twig templates, and Assetic-era asset wiring. There is no service provider, no config/*.php, no Blade view, no Facade. composer require locaine/lcn-wysihtml5-bundle in a Laravel 9+ app fails resolution outright (symfony/form ~2.3 vs symfony/* ^6/^7, PHP ~5.3–5.6 vs 8.1+). Nothing in this repo is autoloadable into a Laravel rendering path.
So the honest day-one answer for a Laravel developer is: you do not install this package — you harvest one design decision from it. The durable idea the repo documents is the pairing of a constrained client-side editor with mandatory server-side HTMLPurifier allow-list sanitisation on submit. That is the pattern to reproduce, and it takes about half a day.
Where to look first (in the repo, ~1 hour of reading):
Resources/config/ — the service definitions. This is the real spec: the form type FQCN (under Lcn\WYSIHTML5Bundle\Form\Type\), the HTMLPurifier config values, and the toolbar/allow-list posture. Copy the config verbatim rather than guessing.Form\Type\ directory — see which editor options are exposed and which are hard-coded.The Laravel translation — your actual first use case: replace one free-text textarea in an admin form with a rich-text field whose submitted HTML is sanitised on write.
composer require mews/purifier # or ezyang/htmlpurifier directly
composer require @tiptap/core @tiptap/starter-kit # or quill, both maintained
// config/purifier.php — mirror the bundle's allow-list posture, not its syntax
'HTML.Allowed' => 'p,h1,h2,h3,h4,strong,em,u,s,ul,ol,li,blockquote,a[href|title],br,img[src|alt|width|height]',
'HTML.Nofollow' => true,
'URI.AllowedSchemes' => ['http' => true, 'https' => true, 'mailto' => true],
'Cache.DefinitionImpl' => null, // test/dev: skip the disk definition cache
First exercise — and it is not about the toolbar. Swap the textarea, submit this payload, and write down exactly what comes back:
<script>alert(1)</script><p style="color:red" onclick="x()">hi</p>
<img src=x onerror=alert(1)>
The output you get back is your content contract. Everything downstream — renderer, diffing, export, search indexing — depends on it, so pin it in a test before you build the editor UI.
If you are genuinely on a legacy Symfony 2.x app (the only context where this bundle runs): composer require locaine/lcn-wysihtml5-bundle:dev-master, register it in app/AppKernel.php, run php app/console assets:install --symlink web/ (plus assetic:dump if used), then ensure the directory behind Cache.SerializerPath is writable by the web user — HTMLPurifier builds a definition cache on first use and throws if it cannot write.
Sanitise on write, in the request layer — never only on read. The bundle hooks HTMLPurifier into the form submission path. The Laravel equivalent is a FormRequest with prepareForValidation() or an explicit sanitize() in validated():
class StoreArticleRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'body' => clean($this->input('body'), 'rich-text'),
]);
}
}
Do not sanitise in the controller — it gets skipped the second someone adds a JSON endpoint or an artisan importer that writes the same column.
Re-sanitise on render as defence in depth, through a single Blade component so there is exactly one place that emits raw HTML:
{{-- resources/views/components/rich-text.blade.php --}}
<div class="prose">{!! clean($html, 'rich-text') !!}</div>
Double-sanitising is cheap relative to a stored-XSS incident, and it covers legacy rows written before the allow-list tightened.
Keep the toolbar and the allow-list in one file. The bundle's real lesson is that the editor's buttons and the sanitiser's allowed tags must be generated from or checked against the same list, or they drift and users watch formatting silently vanish on save. Concretely: define the tag list once, feed it to both HTML.Allowed and the editor's extension set.
// config/richtext.php
return [
'allowed' => ['p','h2','h3','strong','em','ul','ol','li','a','br'],
'toolbar' => ['bold','italic','heading','bulletList','orderedList','link'],
];
Editor as a Blade component with a hidden textarea. This is the bundle's Twig pattern, ported. The editor renders into a div; a hidden input carries the HTML to the server; the component syncs on input.
<div x-data="richText(@js($value))" x-init="init()">
<div x-ref="editor" class="min-h-40 border rounded"></div>
<input type="hidden" name="{{ $name }}" x-ref="input" :value="html">
</div>
Bind to a hidden field rather than a contenteditable — it keeps old(), validation errors, and Livewire/Filament interop working without special cases.
Test the sanitiser, not the editor. A feature test that posts the payload set is worth more than any UI test, because silent bypass is the failure mode that reaches production:
public function test_script_tags_are_stripped_on_write(): void
{
$this->post('/articles', ['body' => '<script>alert(1)</script><p onclick="x()">ok</p>'])
->assertRedirect();
$this->assertStringNotContainsString('<script', Article::first()->body);
$this->assertStringNotContainsString('onclick', Article::first()->body);
$this->assertStringContainsString('<p>ok</p>', Article::first()->body);
}
Cache the sanitised result. HTMLPurifier is CPU-expensive on large documents. Sanitise + store on write, and for the render-path re-check either cache per row (Cache::remember("rt:{$article->id}:{$article->updated_at}")) or accept the cost only for content flagged as unverified. Never run HTMLPurifier over a list of 50 documents on every index page load.
Migrating off the bundle (if you inherited a Symfony app): the mapping is mechanical — Symfony form type → Blade component + FormRequest, service YAML → service provider bindings, Twig template → Blade component, Assetic → Vite. Port the HTMLPurifier profile only; the Symfony form plumbing has no destination. Sequence it as: freeze the allow-list → port the profile verbatim → run both sanitised outputs side by side on a sample of real stored rows → diff → then swap the write path. That diff step is what catches the config drift an untested port hides.
Batch re-sanitisation for legacy content: a queued job that re-runs clean($model->body, 'rich-text') over existing rows, compares to the stored value, and reports (or writes back) the deltas. Run it in dry-run first — it is the only reliable way to find rows that are already storing something your allow-list would now reject.
Composer resolution is a hard stop, and that is the intended outcome. symfony/form ~2.3 cannot coexist with a modern Laravel/Symfony dependency graph. If someone force-installs with --ignore-platform-reqs, the next failure is runtime: Class 'Symfony\Component\Form\AbstractType' not found. Treat both as the framework-fit gate doing its job, not as a bug to work around.
Assets will not load through Vite. The bundle assumes assets:install / Assetic publishing into web/. Vite serves from a manifest and hashed filenames; nothing maps. If you are porting, bundle the editor JS yourself — do not try to point Vite at the bundle's Resources/public.
The silent sanitisation bypass is the failure mode that matters. Config drift (a tag added to the toolbar but not the allow-list, or vice versa), a second write path that skips the FormRequest, or a Cache.DefinitionImpl pointing at a stale cached definition all surface as a security incident rather than an exception. Mitigate with the payload test above, and run the OWASP XSS filter-evasion cheat sheet through it once per allow-list change.
HTMLPurifier needs a writable Cache.SerializerPath and will throw cryptically if it does not have one. In tests and CI, set 'Cache.DefinitionImpl' => null — it skips the disk definition cache entirely, removes a whole class of "works on my machine" failures, and makes the test suite noticeably faster. In production, point the path at storage/framework/cache/purifier and make it writable.
Watch for double escaping. {!! !!} emits sanitised HTML; anything else in the pipeline (a JSON response consumed by a JS renderer, an API resource that escapes, a markdown pass after sanitisation) will turn <p> into <p>. Sanitisation belongs last in the write pipeline and first in the read pipeline — nothing should transform the HTML after clean().
Empty string vs null. WYSIHTML5 (and most editors) submit <p><br></p> or <br> for an "empty" field. That is truthy. Normalise before validation or your required rule passes on a visually blank field:
$body = trim(strip_tags($body, '<img><br>')) === '' && ! str_contains($body, '<img')
? null
: $body;
The editor upstream is abandoned — plan the client side accordingly. WYSIHTML5 predates modern CSP practice and its inline-style approach conflicts with a strict style-src policy. TipTap/ProseMirror, Quill, or Lexical are the maintained options; all of them drop into the hidden-textarea pattern above unchanged, so the server contract never moves.
Bundle config is not your config. If you are reading this repo for reference, do not copy its HTMLPurifier settings wholesale — they are pinned to an old library version and may permit attributes your policy would reject (style is the usual offender). Start from the tag list, add attributes deliberately, and let the test suite prove each one.
Extension points, if you must fork: the HTMLPurifier profile is the only piece worth vendoring. Forking the Symfony form type buys nothing — it is ~200 lines of glue around a framework you are not using, and you would own it forever against a dead upstream dependency.
How can I help you explore Laravel packages today?