flexible-ux/lexical-bundle
The Flexible UX Lexical Bundle brings the Lexical rich text editor to Laravel applications, offering ready-to-use editor components, seamless content handling, and a smooth authoring experience for admin interfaces and CMS pages alike.
Confirm the stack first. This is a Symfony bundle, not Laravel. It only makes sense if the app already runs symfony/asset-mapper, symfony/stimulus-bundle, and symfony/ux-icons. In a Laravel/Inertia codebase it is effectively unusable — you'd be better off wiring Lexical directly via npm/Vite.
composer require flexible-ux/lexical-bundle
php bin/console importmap:require lexical @lexical/rich-text # only if not auto-registered
php bin/console debug:asset-map | grep -i lexical # verify bundle assets are exposed
php bin/console debug:container --tag=form.type | grep -i lexical
That last command is the fastest way to find the exact FQCN of the form type and see which options it accepts, since documentation is thin (9 stars, 0 dependents).
First use case: drop a rich-text field into an existing admin form.
use FlexibleUx\LexicalBundle\Form\LexicalEditorType; // confirm FQCN from debug:container
$builder
->add('body', LexicalEditorType::class, [
'label' => 'Article body',
'required' => false,
]);
Then make sure your base layout calls {{ importmap('app') }} and that the bundle's Stimulus controller is registered (Flex recipe or manual entry in assets/controllers.json). Render the form normally; the widget should mount a contenteditable surface and keep a hidden input in sync.
Where to look first: the bundle's Form/ directory (type + any data transformer), assets/ (the Stimulus controller), and templates/ (form theme block). Those three files answer 90% of "how do I change X" questions.
Store sanitized HTML, not raw JSON, unless you have a reason. Most entities will just have a TEXT column. Persist what the editor emits, but sanitize on the way in — the bundle is an editor, not a security layer.
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
public function __construct(private HtmlSanitizerInterface $sanitizer) {}
public function setBody(?string $html): self
{
$this->body = $this->sanitizer->sanitize($html ?? '');
return $this;
}
Render with {{ article.body|raw }} only after that sanitization step. Configure a dedicated sanitizer profile that allows the tags your toolbar can produce (p, strong, em, ul, ol, li, a, h2, h3, blockquote).
Wrap it once, reuse everywhere. Rather than repeating options in every form:
class ArticleBodyType extends AbstractType
{
public function getParent(): string { return LexicalEditorType::class; }
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'attr' => ['data-lexical-toolbar' => 'bold italic link list'],
'help' => 'Paste from Word is cleaned on save.',
]);
}
}
Normalize "empty" before validation. A NotBlank constraint on an entity field will happily pass on <p></p> or <p><br></p>. Add a Callback or a custom constraint that strips tags and before asserting.
Multiple editors per page. Stimulus binds per-element, so two LexicalEditorType fields in one form generally work, but keep id/name unique and avoid manually hardcoding data-controller on a wrapper — let the form theme do it.
Turbo / Live Components. The controller should re-connect() on turbo:load automatically if it's a standard Stimulus controller. If you swap form fragments via Turbo Frames, verify the hidden input is re-bound; if not, extend the bundle's controller in your own assets/controllers/ and call the parent's connect().
EasyAdmin / custom back offices. Register the type as a field:
yield Field::new('body', 'Body')->setFormType(LexicalEditorType::class);
Watch for EasyAdmin injecting its own form theme — you may need form_theme overrides in templates/bundles/EasyAdminBundle/.
Testing. Functional tests should assert on the rendered hidden input, not the contenteditable div:
$client->submitForm('Save', ['article[body]' => '<p>Hello</p>']);
$this->assertStringContainsString('<p>Hello</p>', $article->getBody());
For JS behaviour, a single Panther/Symfony BrowserKit test covering "type → hidden input updates" is enough; don't try to unit-test Lexical itself.
vendor/; in prod you must run php bin/console asset-map:compile. If the editor renders as nothing, run debug:asset-map | grep lexical and check the browser console for a failed importmap module. importmap:audit catches stale entries.lexical, @lexical/rich-text, @lexical/list, @lexical/link, etc. A missing sub-package shows up as a runtime error only when you click the corresponding toolbar button — not on page load.php bin/console ux:icons:import (or install a set like symfony/ux-icons's lucide/tabler). This is the single most common "looks broken" issue.Content-Security-Policy, you'll need style-src allowances for Lexical's injected styles. It doesn't use eval, so script-src is usually fine once importmap assets are served locally."flexible-ux/lexical-bundle": "0.1.*"), read the Stimulus controller source before relying on it, and keep a thin wrapper form type so you can swap the implementation later without touching every form.vendor/.debug:stimulus to confirm the controller is registered → browser console for importmap 404s → debug:asset-map for the bundle path → verify importmap() and ux_icons() (if used) are in the layout head.|trans yourself. Also verify the hidden input carries a required attribute so HTML5 validation surfaces errors before submit.How can I help you explore Laravel packages today?