Skip to content

[Editor] Add ux-editor with CKEditor, EditorJS and GrapesJS bridges - #3687

Open
makraz wants to merge 39 commits into
symfony:3.xfrom
makraz:feat/ux-editor-core
Open

[Editor] Add ux-editor with CKEditor, EditorJS and GrapesJS bridges#3687
makraz wants to merge 39 commits into
symfony:3.xfrom
makraz:feat/ux-editor-core

Conversation

@makraz

@makraz makraz commented Jun 20, 2026

Copy link
Copy Markdown
Contributor
Q A
Bug fix? no
New feature? yes
Deprecations? no
Documentation? yes
Issues
License MIT

symfony/ux-editor adds a single Symfony form field (EditorType) on top of
multiple content-authoring editors — WYSIWYG, block and page-builder — behind
one consistent API.

The core package is editor-agnostic and provides:

  • a polymorphic content model: EditorContentInterface with HtmlContent,
    BlockContent and PageContent, plus an EditorContentFormat enum;
  • EditorType form abstraction with data transformers;
  • Doctrine custom types (editor_html, editor_blocks, editor_page);
  • a signed upload pipeline (EditorUploadController, SignedUploadUrlGenerator,
    EditorUploadHandlerInterface, local handler + registry);
  • a ux_editor_render Twig function (HTML sanitized, blocks via registry, page
    rendered in a sandboxed iframe);
  • a LiveEditor trait for debounced autosave with LiveComponent;
  • a debug:ux-editor console command and a WebProfiler data collector.

Concrete editors plug in through BridgeInterface + BridgeRegistry. This PR
also includes three bridges as separate composer + npm sub-packages:

  • symfony/ux-editor-ckeditor — CKEditor 5 (WYSIWYG family)
  • symfony/ux-editor-editorjs — EditorJS (block family)
  • symfony/ux-editor-grapesjs — GrapesJS (page-builder family)

PHP and JS test suites are included and passing. Documentation lives in
src/Editor/doc/.

makraz added 30 commits June 20, 2026 17:17
- Add .symfony.bundle.yaml and .gitattributes
- Drop feature-branch composer branch-alias, add author
- Fix assets/package.json symfony block (importmap)
- Expand README with standard sub-tree-split boilerplate
- Support non-controller exports and nested controllers in build
- Rebuild dist
@carsonbot carsonbot added Documentation Improvements or additions to documentation Feature New Feature Status: Needs Review Needs to be reviewed labels Jun 20, 2026
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
Editor
content/EditorContent.d.ts Added 412 B / 246 B
content/EditorContent.js Added 402 B / 230 B
controller-BoYyK5_A.js Added 3.4 kB / 1.2 kB
controller.d.ts Added 1.25 kB / 508 B
controller.js Added 111 B / 109 B
format/block_controller.d.ts Added 446 B / 277 B
format/block_controller.js Added 249 B / 191 B
format/page_builder_controller.d.ts Added 409 B / 257 B
format/page_builder_controller.js Added 368 B / 238 B
format/wysiwyg_controller.d.ts Added 387 B / 251 B
format/wysiwyg_controller.js Added 494 B / 317 B
live/live-editor.d.ts Added 262 B / 206 B
live/live-editor.js Added 541 B / 298 B
upload/SignedUploadClient.d.ts Added 421 B / 255 B
upload/SignedUploadClient.js Added 684 B / 403 B
Editor (Bridge CKEditor)
controller.d.ts Added 511 B / 306 B
controller.js Added 1.25 kB / 559 B
Editor (Bridge EditorJS)
controller.d.ts Added 451 B / 286 B
controller.js Added 1.57 kB / 679 B
tool-registry.d.ts Added 151 B / 159 B
tool-registry.js Added 153 B / 149 B
Editor (Bridge GrapesJS)
controller.d.ts Added 518 B / 317 B
controller.js Added 2.16 kB / 743 B

@makraz makraz changed the title [Editor] Add symfony/ux-editor with CKEditor, EditorJS and GrapesJS bridges [Editor] Add ux-editor with CKEditor, EditorJS and GrapesJS bridges Jun 20, 2026
makraz added 8 commits June 20, 2026 17:50
- Regenerate pnpm-lock.yaml with editor + bridge dependencies (frozen-lockfile)
- Add 'symfony-ux' keywords and fix files: ["dist"] in npm package.json files
- Fix DOCtor-RST: use '::' over code-block:: php, 'javascript' over 'js',
  'applications' over 'apps', matching title underline
- Apply @symfony coding standards (php-cs-fixer)
- Add psr/log ^3 to require (used by AbstractEditorConfig); fixes
  --prefer-lowest builds that pulled the incompatible psr/log 1.x
- Apply oxfmt formatting to TypeScript sources and config files
Bridges require symfony/ux-editor ^0.1|^1.0; the dev-feat/ux-editor-core =>
0.1.x-dev alias is what lets the monorepo path package satisfy that
constraint under composer (incl. --prefer-lowest). Fixes lowest-deps CI.
… path repos

- Doctrine tests: mock AbstractPlatform instead of instantiating SqlitePlatform
  (renamed to SQLitePlatform in doctrine/dbal 4); production types already use
  the version-agnostic AbstractPlatform
- Remove per-bridge 'repositories' path config with symlink:false; the monorepo
  build-packages.php injects path repos (symlinked), and the forced copy was
  mirroring src/Editor/vendor temp files ("Unable to guess file type")
The glob patterns in the core package's exclude-from-classmap caused
Composer to walk the recursive ux-editor path symlink while dumping the
bridge autoloaders, aborting classmap generation so phpunit's classes
were never registered (Class "PHPUnit\TextUI\Application" not found).
Matches the symfony/ux-map layout, which uses an empty array.
@smnandre

Copy link
Copy Markdown
Member

Will review this (great) PR next wednesday!

@smnandre

Copy link
Copy Markdown
Member

I've been reading through it in depth and have started writing a detailed response, but I can already share my main impression: there is more than one bundle in here.

I'm also pretty convinced that we should not try to build a full CMS editor, at least not right now. We should first focus on providing a solid, focused editor abstraction that we or other packages can build on, block by block (pun intended).

That said, this is already impressive work. 👏

If you're willing and want to work on it over the long term, I think it could become a really impressive and useful set of packages for a lot of different people.

I'll post a more detailed response, along with some concrete proposals for how we could move forward, this weekend.

Again, what a nice piece of work!

@makraz

makraz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

I've been reading through it in depth and have started writing a detailed response, but I can already share my main impression: there is more than one bundle in here.

I'm also pretty convinced that we should not try to build a full CMS editor, at least not right now. We should first focus on providing a solid, focused editor abstraction that we or other packages can build on, block by block (pun intended).

That said, this is already impressive work. 👏

If you're willing and want to work on it over the long term, I think it could become a really impressive and useful set of packages for a lot of different people.

I'll post a more detailed response, along with some concrete proposals for how we could move forward, this weekend.

Again, what a nice piece of work!

I really appreciate your feedback.

@smnandre Agree that building a full CMS editor shouldn't be the initial goal. My motivation was primarily to explore what a modern editing experience could look like in the Symfony UX ecosystem, but I'm completely on board with refocusing it around a solid editor abstraction and letting higher-level features evolve as separate packages.

I'm definitely interested in working on this over the long term, so I'd be very happy to split things up and iterate package by package if that's the direction we decide to take.

Looking forward to your detailed feedback. Thanks again for the encouraging words, they're really motivating! 🚀

@smnandre

Copy link
Copy Markdown
Member

(it's coming it's coming 😅 )

@smnandre

smnandre commented Aug 2, 2026

Copy link
Copy Markdown
Member

Note

Full disclosure: I used an LLM to review, correct, and adapt this text so that it communicates my thoughts and intended meaning as clearly as possible.

Overall direction

First, this is a great piece of work. The PR is ambitious, contains many good ideas, and gives us a lot of concrete code to discuss. Thanks again for that! 🤝

It also asks many useful questions about editor integrations, content models, rendering, uploads, page builders, and the boundaries of Symfony UX. This exploration is already very valuable.

As I see it, the PR addresses a real need: integrating JavaScript editors with Symfony Forms is still harder than it should be.

My main concern is the responsibility of the core package.

--

The PR describes symfony/ux-editor as one form field for WYSIWYG editors, block editors, and page builders. However, the implementation does much more than integrate editor UIs. It also defines the content model, storage shapes, Doctrine types, rendering, uploads, autosave, conversion, and debugging.

This makes the central question important before we stabilize the public API:

Is UX Editor an integration layer for editor UIs, or a content-authoring framework?

In my view, the current code is much closer to the second option.

The proposed content abstraction

The core starts with one interface for all edited content:

interface EditorContentInterface
{
    public function getFormat(): EditorContentFormat;

    public function getRaw(): string|array;

    public function getMetadata(): array;

    public function isEmpty(): bool;
}

It then defines three formats:

enum EditorContentFormat: string
{
    case Html = 'html';
    case Blocks = 'blocks';
    case Page = 'page';
}

I am not convinced that these values form one "useful" domain abstraction. Let me explain 😅...

HtmlContent contains one HTML string. BlockContent contains versioned typed blocks. PageContent contains HTML, CSS, assets, and components. Their common raw value is therefore string|array, which means consumers still need to know the real type.

The differences also appear directly in their methods. Blocks can be filtered by type. Pages can expose assets. HTML can be sanitized. These are separate domain operations, not variations of the same operation.

I think the common interface does not remove these differences. It mainly places all three models inside the Editor package.

The form type defines the application model

EditorType is presented as a common form field, but its transformer requires application data to implement EditorContentInterface:

if (!$value instanceof EditorContentInterface) {
    throw new TransformationFailedException(
        'Expected EditorContentInterface, got '.get_debug_type($value)
    );
}

This means that using CKEditor for a normal description field requires the application to replace a regular string with HtmlContent.

In my view, the form integration is therefore not neutral. It asks the application to adopt the bundle's content model, even for the smallest rich-text use case.

I think we should keep the default contract as the natural value of the Symfony field. For CKEditor, that is usually string|null. If an application wants a value object, it can add a form transformer or install an optional integration.

In my opinion, the form type should make the editor easier to mount. It should not decide how the domain entity stores its content.

The bridge contract mixes UI and content responsibilities

The proposed BridgeInterface combines five responsibilities:

interface BridgeInterface
{
    public function getId(): string;
    public function getControllerName(): string;
    public function getDefaultConfig(): EditorConfigInterface;
    public function getCapabilities(): BridgeCapabilities;
    public function createTransformer(): EditorContentTransformerInterface;
}

In my view, the controller name and configuration belong to the editor integration.

I see the transformer as part of the data model. It decides the content class and whether storage is scalar, JSON, or split:

enum StorageShape: string
{
    case Scalar = 'scalar';
    case Json = 'json';
    case Split = 'split';
}

Coupling these concerns means that selecting a JavaScript editor also selects a PHP content type and a storage shape.

I think that coupling is reasonable only if we want UX Editor to own the complete authoring model. It is too strong if we only want to integrate an editor into a form.

I think we should keep the provider contract focused on the UI integration: controller, configuration, assets, lifecycle, and events. In my view, content transformation should stay optional and separate.

The three format tiers expose three different contracts

The architecture introduces three abstract families:

  • AbstractWysiwygBridge;
  • AbstractBlockBridge;
  • AbstractPageBuilderBridge.

Each family then has its own config, transformer, capabilities, and JavaScript controller.

The JavaScript interfaces show the difference clearly.

The WYSIWYG contract returns HTML:

interface WysiwygInstance {
    getHTML(): string;
}

The block contract saves a document asynchronously:

interface BlockInstance {
    save(): Promise<{
        version?: string;
        blocks: Array<{ type: string; data: Record<string, unknown> }>;
    }>;
}

The page contract manages several coordinated resources:

interface PageInstance {
    getHtml(): string;
    getCss(): string;
    getComponents(): unknown[];
    getAssets(): unknown[];
}

In my view, these are not only three editor implementations. They are three different document contracts.

The separate abstract families make this explicit, but they are still shipped as layers of one Editor core. I think this is a sign that the common abstraction is being placed one level too high.

The "useful" common abstraction is the editor lifecycle

(re-using the same term here, but it should be read as my personal opinion and not given more value than that)

AbstractEditorController contains the most convincing shared responsibility in the PR.

It defines:

  • mounting and destroying an editor;
  • synchronizing the editor value with a form input;
  • emitting lifecycle and change events;
  • applying configuration changes;
  • remounting when a setting cannot be updated live.

I think this is a good foundation for a Symfony UX component.

However, the current lifecycle is broad in formats and still incomplete in the basic form direction. connect() creates the editor from the mount element and config, but it does not pass the current field value to the provider. The abstract contract has serialize(), but no matching hydration operation.

this.instance = await this.createEditor(this.mountTarget, this.configValue);

There is also no common submit hook that waits for asynchronous serialization before the form is submitted.

The form theme hides the textarea with display:none, so initialization failures do not currently leave a usable fallback field.

I think we should first make this lifecycle complete: initial hydration, change synchronization, submit flushing, reset, failure fallback, Turbo reconnection, and teardown. In my view, this is more valuable as a core abstraction than supporting three document families from the first version.

Common options and capabilities

The PR adds CommonOptions for toolbar, placeholder, read-only mode, height, theme, language, plugins, autofocus, and spellcheck.

It then adds BridgeCapabilities to report which common options each bridge supports.

This creates a normalization layer between Symfony and every editor API. The current code already shows its limits:

  • each abstract format translates a different subset of the options;
  • capability checks cover only some common options;
  • CKEditor overrides the generic WYSIWYG translation;
  • providers still need nativeOverrides for their real APIs.

I think provider-specific config objects are useful. CKEditorConfig, EditorJSConfig, and GrapesJSConfig can give users typed help without pretending that the tools share the same options.

I think we should start with these provider configs and a native escape hatch. In my opinion, we should add a common option only after at least two providers prove that it has the same meaning and behavior.

The core owns persistence and rendering

The PR adds three Doctrine types:

editor_html
editor_blocks
editor_page

It also adds ux_editor_render(), which handles each concrete content class with an instanceof match.

return match (true) {
    $content instanceof HtmlContent => $this->renderHtml($content),
    $content instanceof BlockContent => $this->renderBlocks($content),
    $content instanceof PageContent => $this->renderPage($content, $options),
    default => '',
};

In my view, this creates a closed content system inside a package presented as editor-agnostic. Adding another content model requires either changing the core renderer or staying outside ux_editor_render().

The rendering rules are also domain-specific:

  • HTML uses HtmlSanitizerInterface;
  • blocks use a BlockRendererRegistry;
  • pages are rendered inside an iframe with generated srcdoc.

I think these are valid features, but they are not editor lifecycle concerns. An editor can edit content without owning its Doctrine mapping or frontend rendering.

I do not think we should make persistence and rendering part of the first Editor contract. We can document them as application integrations or provide them through packages that own the related content model.

Conversion is not a generic editor operation

The core also defines ContentConverterInterface:

interface ContentConverterInterface
{
    public function getFrom(): string;
    public function getTo(): string;
    public function convert(EditorContentInterface $content): EditorContentInterface;
}

I do not think converting HTML, blocks, and complete pages is a simple technical adapter. It raises questions about lost formatting, unsupported block types, assets, CSS, metadata, and round trips.

The current interface does not express whether a conversion is lossy or reversible. In my view, this is another sign that content conversion should not be a basic responsibility of an editor integration package.

The concrete bridges confirm the boundary

CKEditor

CKEditorBridge is small. It selects a controller, a typed config, and an HTML transformer.

In my view, this is close to the main value we should expect from UX Editor: make a third-party editor work correctly with a Symfony field.

EditorJS

The EditorJS package includes more than a UI adapter. It defines tool configuration, a block transformer, a block schema, and server-side renderers for paragraphs, headers, lists, quotes, and images.

I think those features are useful, but they form a block-content system. The editor integration is only one consumer of that system.

GrapesJS

The GrapesJS package works with HTML, CSS, components, assets, devices, blocks, and a storage manager. The core also provides page asset extraction and iframe rendering.

To me, this is a page-builder model, not simply a richer textarea.

In my view, putting all three tools behind EditorType makes their form entry point look similar, but it does not make their responsibilities similar.

Upload and autosave increase the core contract

The core package provides a complete upload path:

  • an upload route and controller;
  • signed tokens;
  • handler profiles and a registry;
  • a default local handler writing into public/uploads;
  • a JavaScript upload client;
  • an upload_url form option.

It also provides the LiveEditor trait and a JavaScript debounce helper for draft autosave.

These features can be used by editors, but I do not think they are required to mount, hydrate, synchronize, or submit an editor field. They also introduce application decisions about storage, authorization, draft persistence, and error handling.

The debug:ux-editor command makes the resulting scope visible: it lists bridges, presets, content converters, and upload handlers as parts of the same system.

I think we should keep the first package focused on the editor field lifecycle. We can integrate upload and autosave through events and optional adapters without making the core own them.

What I think we should keep in this PR

I think we should keep and strengthen:

  • EditorType as a progressive enhancement of a normal Symfony field;
  • a small provider registry;
  • provider-specific typed configuration;
  • native configuration overrides;
  • lazy loading of editor packages;
  • the shared Stimulus lifecycle;
  • public lifecycle and change events;
  • clean teardown and Turbo support;
  • one focused provider package, with CKEditor as a good first case.

The first complete path could be:

Symfony string field
    <-> EditorType
    <-> shared editor lifecycle
    <-> CKEditor provider
    <-> HTML string

This smaller scope would let us validate the abstraction with a complete real use case before defining models for every kind of authoring tool.

What I think we should defer from this PR

Based on the code currently proposed, I think we should defer:

  • EditorContentInterface and the HtmlContent / BlockContent / PageContent hierarchy;
  • EditorContentFormat and StorageShape as core editor concepts;
  • format-level bridge families;
  • Doctrine content types;
  • ux_editor_render();
  • the content converter registry;
  • the upload controller, signer, handlers, and default storage;
  • the LiveComponent draft abstraction;
  • the page asset extractor and iframe renderer;
  • broad common options and capability matrices.

This does not mean the work has no value. In my view, it means we should discuss these APIs as separate responsibilities before making them part of the base bundle.

Possible follow-up components

Note

Full disclosure vol.2: I may be biased here.. I just have UX Upload open as a PR, and will open one for UX Image tomorrow :)

I mention this because the code in this PR naturally raises the same responsibility questions. I do not think this PR should depend on those proposals.

If we reduce the Editor responsibility, I think the current code suggests some natural follow-up components.

UX Blocks

In my view, BlockContent, schema versions, block validation, block renderers, and EditorJS transformation could become a block-content component. EditorJS would then be an editor integration for that component, instead of making blocks part of every UX Editor installation.

UX Upload

I think the signed upload client, handlers, profiles, progress, storage result, and authorization contract could become an autonomous upload component. We could let editors use it through an optional integration.

UX Image

The EditorJS image renderer and the image-focused default upload handler already raise image-specific questions. In my view, image metadata, variants, transformations, and responsive rendering may deserve their own responsibility rather than being hidden inside an editor upload.

UX Page

I think we could explore PageContent, components, assets, CSS, page extraction, sandbox rendering, and GrapesJS integration as a page-authoring component with its own stronger contract.

I do not think we should make these components prerequisites for the basic Editor use case. We could later provide an integration package that connects them for applications wanting the complete authoring experience.

Personal position

I support the goal of making editor integrations easy in Symfony Forms.

I do not think the current common content model is the right foundation for that goal. The code shows that HTML, block documents, and pages need different models, storage rules, renderers, and integrations.

I think we should reduce this PR to the field and provider lifecycle, complete that path with one editor, and discuss the other responsibilities separately.

The current work is still very, very useful: it identifies most of the problems an authoring ecosystem will need to solve.

In my view, we should use it as exploration for smaller public contracts, rather than merge all of those contracts as one ux-editor abstraction.

@smnandre

smnandre commented Aug 2, 2026

Copy link
Copy Markdown
Member

Questions about CKEditor licensing

I may be missing something, and this is not a legal conclusion. Before going further, I think we should ask two questions: are we comfortable providing an official MIT bridge for an editor licensed under GPL 2+ or commercial terms? More importantly, are we legally allowed to do so under the licenses involved?

CKEditor dual-licens

CKEditor 5 is dual-licensed: GPL 2+ or commercial. Its Free Plan is commercial. For self-hosted use, 'GPL' selects GPL terms.

The PR exposes licenseKey, and both presets select 'GPL'. Could users miss this choice? The package requires ^41, while the docs discuss version 44 licensing. Which flow are we supporting?

Symfony and Symfony UX licensing

Symfony is MIT, as is the proposed bridge. CKEditor remains separately licensed. Could an official MIT bridge nevertheless make CKEditor appear MIT-compatible?

What could we do?

  • Remove 'GPL' from the presets.
  • Require an explicit license choice.
  • Document both licenses and align the supported version.
  • Request a licensing review before going further.

I have no qualifications on these topics. Nor opinions. But I guess we probably want to answer this question before investing further in the integration and its public API.

@davidromani

Copy link
Copy Markdown

Could be possible to add a Lexical bridge?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Feature New Feature Status: Needs Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants