ehyiah/ux-quill
Symfony UX bundle that adds a Quill.js WYSIWYG form type with minimal configuration. Works out of the box with AssetMapper, Webpack Encore, and EasyAdmin, and includes a Twig component for rendering content with or without Quill styles.
ehyiah/ux-quill is a Symfony UX bundle, not a framework-agnostic editor package. It ships a Symfony Form type (QuillType), a Twig component (<twig:QuillContent />), a Twig function (quill_content_styles()), an EasyAdmin field (QuillAdminField), and a Stimulus controller. None of that registers in Laravel: there is no service provider, no Blade component, no config/ux_quill.php, and composer require ehyiah/ux-quill in a Laravel app will pull in symfony/form, symfony/ux-* and Twig without ever wiring anything up.
So "getting started" as a Laravel developer means one of two things:
composer require ehyiah/ux-quill
AssetMapper users are done. Webpack Encore users still need:
yarn install --force && yarn watch
# or
npm install --force && npm run watch
Register the field:
use Ehyiah\QuillJsBundle\Form\QuillType;
$builder->add('content', QuillType::class, ['preset' => 'default']);
Render saved HTML:
{{ quill_content_styles() }} {# AssetMapper only #}
<twig:QuillContent value="{{ post.content }}" />
{# or, no frontend CSS: #}
<twig:QuillContent value="{{ post.content }}" style="inline" />
https://ehyiah.github.io/ux-quill/ — the README explicitly points there as the "new documentation", which tells you the README alone is incomplete.src/Form/QuillType.php — the options resolver is the real API surface. Release 3.5.1 explicitly hardened it so "all default values are present"; this is your authoritative list of options, not the docs.src/Form/Field/ — the QuillGroup / field classes (ListField, Divider, PageBreak, TableField…) are what the toolbar presets are composed from. This is the most copy-worthy code in the repo.assets/controllers/ — the Stimulus controller. This is where option merging, hydration of initial content, and the runtime event hooks live.changelog/PHP.md and changelog/Javascript.md — short, but they document the breaking changes that invalidate most blog snippets you'll find.Build a <x-quill-editor> Blade component backed by Alpine (or a Livewire child component) that mirrors QuillType's defaults, and a <x-quill-content> Blade component that mirrors <twig:QuillContent>. Copy the preset toolbar arrays out of the Symfony field classes rather than inventing your own — that's the actual payoff of reading this package.
{{-- resources/views/components/quill-editor.blade.php --}}
<div
x-data="quill({ value: @js($value), options: @js($options) })"
wire:ignore
>
<div x-ref="editor"></div>
<input type="hidden" name="{{ $name }}" x-ref="input">
</div>
{{-- resources/views/components/quill-content.blade.php --}}
@props(['value', 'style' => 'classes'])
@if ($style === 'inline')
<div>{!! $value !!}</div>
@else
<div class="ql-snow"><div class="ql-editor">{!! $value !!}</div></div>
@endif
That ql-snow / ql-editor wrapper is the single most portable detail in the whole README — it's what makes saved content look correct on the frontend.
QuillType resolves a flat-ish options array (quill_options, quill_extra_options, preset, upload_handler, and — since 2.1.0 — modules at the top level). Recreate that shape as a Laravel config file so your Blade component and your FormRequest validation agree on it:
// config/quill.php
return [
'presets' => [
'minimal' => [
'toolbar' => [['bold', 'italic'], ['link'], ['clean']],
],
'default' => [
'theme' => 'snow',
'toolbar' => [
['bold', 'italic', 'underline', 'strike'],
[['list' => 'ordered'], ['list' => 'bullet']],
['link', 'image'],
['clean'],
],
],
],
];
Merge precedence matters — in the bundle, explicit quill_options win over the preset, and quill_extra_options is a second-tier escape hatch. Reproduce that order deliberately.
The bundle's Stimulus controller does three things you must replicate: instantiate Quill, hydrate initial content in JS (not in the template — see the changelog note about the table module), and keep a hidden input in sync for form submission.
// resources/js/quill.js
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
export default (config) => ({
quill: null,
init() {
this.quill = new Quill(this.$refs.editor, config.options);
if (config.value) {
// hydrate via clipboard so Quill's own parser normalises it
this.quill.clipboard.dangerouslyPasteHTML(config.value);
}
this.quill.on('text-change', () => {
this.$refs.input.value = this.quill.root.innerHTML;
});
},
});
Note the ordering trap: the bundle moved initial-content hydration from Twig into the JS init specifically because of the table module. If you set innerHTML on the editor div in Blade and hydrate in JS, you'll get doubled content or broken tables.
Release 3.0.0 added Live Component compatibility and 3.4.1 fixed "a bug causing content to be empty" in that context — a strong signal that this is the fragile area. In Laravel:
wire:ignore on the editor container so Livewire morphing doesn't nuke Quill's DOM.$wire.set('content', html, false) on a debounce, not on every keystroke.morph.updated, re-check whether Quill's root is still attached; re-init if not.The bundle's upload_handler shape (renamed from path in 2.1.0) is worth copying verbatim:
'upload_handler' => [
'upload_endpoint' => '/api/uploads',
'headers' => ['Authorization' => 'Bearer ...'],
]
Release 2.3.0 added custom header name/value for endpoint auth; use that instead of baking a Bearer token into a JS file. In Laravel this maps to:
Route::post('/api/uploads', UploadController::class)
->middleware(['auth:sanctum', 'throttle:20,1']);
…returning { "url": "https://cdn/..." }. Validate MIME and size server-side — Quill's client-side image handler is trivially bypassed, and this is the one place where a WYSIWYG turns into an unrestricted file-upload endpoint if you're careless.
The 2.0.4 release draws a useful line: some modules are pure configuration (History), others need JS (Clipboard, Keyboard). Release 3.6.0 added a large batch — Markdown, Mention, Image Gallery, Autosave, pasteSanitizer, linkAttribute — plus Divider and PageBreak fields. When porting, classify each one:
| Module | Laravel port effort |
|---|---|
| History, Counter, Table | config array only |
| Autosave | config + a persistence endpoint |
| Mention | config + JSON source route |
| Markdown | config + a renderer on output (league/commonmark) |
| Reading time, Speech-to-text | small custom JS modules |
| Image Gallery | genuinely custom; needs its own upload + picker UI |
Don't port modules you don't need. The repo's own changelog is a list of bugs caused by modules interfering with each other (3.0.1: "quill instance was crashing if TableField was not used").
Release 2.0.7 added a Stimulus event to mutate Quill options from the controller. The framework-neutral equivalent is a CustomEvent dispatched before init:
window.addEventListener('quill:options', (e) => {
Object.assign(config.options, e.detail);
});
Useful for per-tenant toolbars or feature flags without rebuilding the bundle.
The bundle removed symfony/html-sanitizer and all sanitize_html options in 3.0.0, with an explicit rationale: pre-Quill sanitization was "preventing the table module from working". The correct Laravel analogue is HTMLPurifier (mews/purifier) or a strict allowlist applied in a FormRequest before persistence, never on the way into the editor.
$data = $request->validate(['content' => ['required', 'string', 'max:200000']]);
$clean = clean($data['content'], 'quill'); // config/purifier.php allowlist
Tune the allowlist to include table, thead, tbody, tr, td, th, colspan, rowspan if you enable the table module — otherwise you'll silently strip table layout on save and blame Quill.
It will not install into Laravel. This is the headline gotcha and worth repeating: 63 stars, 0 dependents, and a hard dependency on Symfony Form + Twig + StimulusBundle. Composer will resolve, but nothing will work. If you need Quill 2 in Laravel today, vue-quill / react-quill / a hand-rolled Alpine wrapper is a shorter path; use this repo for its option shapes and its module taxonomy.
Docs and source disagree by version. path → upload_endpoint (2.1.0), modules moved to top level (2.1.0), sanitizer options removed (3.0.0), ModuleInterface gained a constructor (2.x). Most search results for this package are from the 1.x/2.0 era. Pin your reading to the changelog before copying any snippet.
Asset loading: quill_content_styles() is AssetMapper-only. In a Laravel app with Vite, there is no equivalent — you must import 'quill/dist/quill.snow.css' in your entry and ensure the saved-content wrapper carries ql-snow/ql-editor classes. Symptom of getting this wrong: content renders but with no spacing, no list bullets, no table borders.
Tailwind preflight will break the editor. Quill ships its own reset-ish styles; Tailwind's base layer strips list styles and table borders inside .ql-editor. If you're porting, either scope the editor away from Tailwind's base or use the bundle's style="inline" approach for output (the 2.0.3 release added inline styling precisely as an escape hatch).
Dynamic imports were reverted for a reason. Release 2.1.1 reverted dynamic JS imports "because of webpack compatibility problems." Keep Quill and its modules as static imports in your entry point; lazy-loading Quill per-editor is not worth the debugging session.
Turbo/Livewire import races. Release 3.4.2 moved KaTeX and highlight.js imports out of Twig into JS files due to a race condition in a Turbo context. Translate directly: import third-party renderers at module scope in your app.js, not conditionally inside a component.
Multiple editors per page work, but require discipline. The README confirms multiple QuillType fields per page are supported. In Livewire that means unique refs ($refs.editor collides on re-render) — use wire:key and x-id, or one child component per editor.
Table module is the fragile one. It was the reason for the JS-hydration change, the reason sanitizer options were dropped, and the subject of a crash fix in 3.0.1 when TableField wasn't used. If tables matter to you, test them first and test them after any options change.
Nested options handling changed for Symfony 8 (3.8.0). If you're writing your own options resolver in Laravel, don't nest toolbar inside modules — Quill 2 expects toolbar under modules in the Quill constructor but the bundle exposes it at the top level for DX and flattens it in JS. Copy the flattening logic, not the flattened shape.
Placeholder accepts a translatable object (3.3.2). The Laravel equivalent is just __('editor.placeholder') resolved in the component — but it reminds you that any user-facing string in your options array needs @js() escaping when injected into x-data.
Extension points worth knowing about: ModuleInterface (constructor + registration), QuillFieldModuleInterface, custom toolbar icons (2.1.0), and the ability to override the built-in KaTeX/highlight assets with your own stylesheets/JS (3.0.0). The last one is the pattern to steal if you want to swap Quill's bundled syntax highlighter for Shiki.
Debugging order: (1) is Quill's CSS actually loaded, (2) is the content hydration happening in JS only, (3) are nested option keys where Quill expects them, (4) is the table module enabled without a matching sanitizer allowlist. Roughly 90% of "the editor is broken" reports against this package land on one of those four.
Bus factor. Single maintainer, small star count, no dependents — but releases are frequent (3.4 → 3.8 across recent history) and the changelog is well-kept. Treat it as a moving target: if you're porting, snapshot the commit you read and note it in your own code, because the option names have already changed twice.
How can I help you explore Laravel packages today?