Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ux Quill Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

The honest starting point

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:

  1. You are working in a Symfony codebase (or a hybrid monorepo) and need to use it properly — then follow the README install path below.
  2. You are in a Laravel/Livewire app and want the Quill 2.x experience this bundle provides — then treat this repo as a reference implementation to port, not a dependency.

Minimal steps (if you genuinely have a Symfony app)

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" />

Where to look first

  • Docs site: 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.

First realistic Laravel use case

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.


Implementation Patterns

1. Port the options shape, not the PHP

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.

2. Alpine/Livewire editor lifecycle

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.

3. Livewire specifics

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:

  • Put wire:ignore on the editor container so Livewire morphing doesn't nuke Quill's DOM.
  • Sync via $wire.set('content', html, false) on a debounce, not on every keystroke.
  • On morph.updated, re-check whether Quill's root is still attached; re-init if not.
  • Import KaTeX / highlight.js in your JS entry point, not in the Blade template — release 3.4.2 changed exactly this because of a Turbo race condition, and Livewire has the same class of race.

4. Image uploads

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.

5. Modules: PHP-only vs JS-required

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").

6. Runtime option overrides

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.

7. Sanitization: on save, not on render

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.


Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core