CKEditor 5 for Symfony >=6.4.x — a lightweight WYSIWYG editor integration for Symfony. Easy to set up, it supports custom builds, dynamic loading, and localization. The package includes JavaScript and CSS assets, making it simple to integrate CKEditor 5 into your Symfony applications.
Important
This integration is unofficial and not maintained by CKSource. For official CKEditor 5 documentation, visit ckeditor.com. If you encounter any issues in editor, please report them on the GitHub repository.
- ckeditor5-symfony
| CKEditor 5 Version | Integration Version |
|---|---|
| 43.x – 47.x | <= 1.8.x |
| >= 48.0 | >= 1.9.x |
-
Install the package:
composer require mati365/ckeditor5-symfony
-
Enable the bundle:
// config/bundles.php return [ // ... Mati365\CKEditor5Symfony\CKEditorBundle::class => ['all' => true], ];
-
Run the installer:
Choose the distribution method that best fits your needs:
🏠 Self-hosted via AssetsMapper (Recommended) Bundles assets locally. No Node.js required.
php bin/console ckeditor5:assets-mapper:install # --editor-version 48.2.0 --premium --translations en,pl📦 Self-hosted via Webpack Encore Requires Node.js and a bundler like Webpack Encore. Assets are bundled with your application.
npm install ckeditor5 ckeditor5-premium-features ckeditor5-symfony --dev
and then add the following line to your main JavaScript file (e.g.,
assets/app.js):import 'ckeditor5-symfony'; import 'ckeditor5/ckeditor5.css';
📡 CDN Distribution Loads assets from CKSource CDN.
php bin/console ckeditor5:assets-mapper:install --distribution=cloud
For CDN, add
CKEDITOR5_LICENSE_KEY="your-key"to your.envfile.💡 Tip: Add
--premiumto either command to install premium features (requires a valid license).For more options, see Installer command options.
To use CKEditor 5 in your Twig templates, simply call the cke5_editor() function:
{{ cke5_editor("Hello World")}}
{# Or ... #}
{{ cke5_editor(
content: '<p>Hello world!</p>', {# initial HTML content; pass an array for multiroot: {'main': '<p>...</p>'} #}
id: 'my-editor', {# unique ID; auto-generated with "cke5-" prefix if omitted #}
preset: 'default', {# preset name from config or a Preset object; default: 'default' #}
editorType: 'classic', {# classic | inline | balloon | decoupled | multiroot #}
editableHeight: 300, {# fixed height in px; editor grows with content if omitted #}
language: 'pl', {# UI language (toolbar, dialogs); pass array for advanced config #}
saveDebounceMs: 200, {# debounce in ms for syncing content to the hidden input (default: 200) #}
watchdog: true, {# auto-restart the editor on crash (default: true) #}
name: 'content', {# name attribute on the hidden input field (e.g. for Symfony forms) #}
required: true, {# adds required attribute to the hidden input field #}
contextId: 'my-context', {# ties this editor to a shared CKEditor context instance #}
config: { toolbar: ['bold'] }, {# shallow-replaces the preset's editor config #}
mergeConfig: { toolbar: { shouldNotGroupWhenFull: true } }, {# deep-merges with the preset's editor config #}
customTranslations: { {# override built-in UI strings per language #}
en: { Bold: 'Strong' },
pl: { Bold: 'Pogrubienie' }
},
class: 'my-editor', {# CSS classes on the outer container element #}
style: 'border: 1px solid #ccc', {# inline styles on the outer container element #}
) }}If you don't use AssetsMapper, and your distribution is set to cloud, make sure to include the assets using cke5_cloud_assets() in your <head> section.
{{ cke5_cloud_assets() }}By default, the editor uses a built-in watchdog mechanism to automatically restart the editor if it crashes (e.g., due to a JavaScript error). The watchdog periodically saves the editor's content and restores it after a crash, minimizing the risk of data loss for users.
The watchdog is enabled by default. To disable it, set the watchdog argument to false:
{{ cke5_editor(
content: '<p>Initial content</p>',
editorType: 'classic',
watchdog: false
) }}CKEditor 5 Symfony supports four distinct editor types, each designed for specific use cases. Choose the one that best fits your application's layout and functionality requirements.
Traditional WYSIWYG editor with a fixed toolbar above the editing area. Best for standard content editing scenarios like blog posts, articles, or forms.
{{ cke5_editor(
content: '<p>Initial content here</p>',
editorType: 'classic',
editableHeight: 300
) }}Advanced editor supporting multiple independent editable areas within a single editor instance. Perfect for complex layouts like page builders, newsletters, or multi-section content management.
{# Editor container #}
{{ cke5_editor(editorType: 'multiroot') }}
{# Shared toolbar #}
{{ cke5_ui_part('toolbar') }}
{# Multiple editable areas #}
<div class="row">
<div class="col">
<h2>Header</h2>
{{ cke5_editable(rootName: 'header', content: 'Header content', class: 'border') }}
</div>
<div class="col">
<h2>Content</h2>
{{ cke5_editable(rootName: 'content', content: 'Main content', class: 'border') }}
</div>
</div>Minimalist editor that appears directly within content when clicked. Ideal for in-place editing scenarios where the editing interface should be invisible until needed.
{{ cke5_editor(
content: '<p>Click here to edit this content</p>',
editorType: 'inline',
editableHeight: 300
) }}Note: Inline editors don't work with <textarea> elements and may not be suitable for traditional form scenarios.
Contextual editor that shows a floating toolbar when text is selected. Great for simple editing tasks where a full toolbar isn't necessary.
{{ cke5_editor(
content: '<p>Select some text to see the balloon toolbar</p>',
editorType: 'balloon',
editableHeight: 300
) }}Flexible editor where toolbar and editing area are completely separated. Provides maximum layout control for custom interfaces and complex applications.
{# Decoupled editor container #}
{{ cke5_editor(id: 'decoupled-editor', editorType: 'decoupled') }}
<div class="editor-container">
{# Toolbar can be placed anywhere #}
{{ cke5_ui_part('toolbar') }}
{# Editable area with custom styling #}
{{ cke5_editable(
content: '<p>Initial content here</p>',
class: 'border p-4 rounded',
editableHeight: 300
) }}
</div>You can configure the editor presets in your config/packages/ckeditor5.yaml file. The default preset is default, which provides a basic configuration with a toolbar and essential plugins. The preset is a map that contains the editor configuration, including the toolbar items and plugins. There can be multiple presets, and you can switch between them by passing the preset keyword argument to the cke5_editor function.
In order to override the default preset or add custom presets, you can add the following configuration:
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
minimal:
editorType: classic
config:
toolbar: [bold, italic, link]
plugins: [Bold, Italic, Link, Essentials, Paragraph]
# Only if don't use AssetsMapper and using cloud distribution. Otherwise, this section is
# automatically configured during `ckeditor5:assets-mapper:install` command.
cloud:
version: 46.0.0
premium: true
translations: [pl]
ckbox:
version: 1.0.0
full:
editorType: classic
watchdogConfig:
crashNumberLimit: 3
config:
toolbar:
- heading
- '|'
- bold
- italic
- underline
- '|'
- link
- insertImage
- insertTable
- '|'
- bulletedList
- numberedList
- blockQuote
plugins:
- Heading
- Bold
- Italic
- Underline
- Link
- ImageBlock
- ImageUpload
- Table
- List
- BlockQuote
- Essentials
- ParagraphIn template:
{{ cke5_editor(content: '<p>Simple editor</p>', preset: 'minimal') }}The default one is specified here src/DependencyInjection/DefaultConfiguration.php.
You can also override configuration at runtime by passing config or mergeConfig arguments to the editor function. This is useful if you want to change the editor configuration based on user input or other conditions.
{# Override configuration (shallow merge) #}
{{ cke5_editor(
content: 'Content',
config: { 'toolbar': ['bold', 'italic'] }
) }}
{# Merge configuration (deep merge) #}
{{ cke5_editor(
content: 'Content',
mergeConfig: { 'toolbar': { 'items': ['bold'] } }
) }}For more complex scenarios, you can create preset configurations programmatically in your controller using PresetParser::parse(). This is useful when you need to build editor configurations based on user permissions, database settings, or other runtime conditions.
use Mati365\CKEditor5Symfony\Preset\PresetParser;
class ArticleController extends AbstractController
{
#[Route('/article/edit')]
public function edit(): Response
{
return $this->render('article/edit.html.twig', [
'preset' => PresetParser::parse([
'editorType' => 'balloon',
'config' => [
'toolbar' => ['bold', 'italic', 'undo', 'redo'],
'plugins' => ['Essentials', 'Paragraph', 'Bold', 'Italic', 'Undo'],
],
]),
]);
}
}Then in your template, pass the preset object directly:
{{ cke5_editor('Your content here', preset: preset) }}This approach gives you full control over editor configuration at runtime, allowing you to customize it based on application logic, user roles, or any other dynamic conditions.
CKEditor 5 requires a license key when using the official CDN or premium features. You can provide the license key in two simple ways:
-
Environment variable: Set the
CKEDITOR5_LICENSE_KEYenvironment variable in your.envfile. This is the easiest and most common way. -
Preset config: You can also set the license key directly in your preset configuration in
config/packages/ckeditor5.yaml:ckeditor5: presets: custom: # ... licenseKey: your-license-key-here
If you use CKEditor 5 under the GPL license, you do not need to provide a license key. However, if you choose to set one, it must be set to GPL.
If both are set, the preset config takes priority. For more details, see the CKEditor 5 licensing guide.
You can reference DOM elements directly in your editor configuration using the special { $element: "selector" } format. This is useful when you want to attach the editor's UI parts (like toolbars or editable areas) to specific elements in your HTML.
- In your config object, use
{ "$element": "CSS_SELECTOR" }wherever a DOM element is expected. - The selector will be resolved to the actual DOM element before initializing the editor.
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
# ... other presets
minimal:
config:
# ... other config
yourPlugin:
toolbar: {$element: '#my-toolbar'}
editable: {$element: '#my-editable'}This will find the elements with IDs my-toolbar and my-editable in the DOM and use them for the editor's UI.
Support multiple languages in the editor UI and content. Learn how to load translations via CDN or configure them globally.
Depending on your setup, you can preload translations via CDN.
If you want to load a specific language for the editor UI, you can specify the language argument in the cke5_editor() function:
{{ cke5_editor(
language: 'pl',
content: '<p>Content in English, UI in Polish</p>'
) }}Remember, that this only works if you have installed the translations using the ckeditor5:assets-mapper:install command with the --translations option.
php bin/console ckeditor5:assets-mapper:install --translations=pl,de,frIf you don't use AssetsMapper, you can load translations directly from CDN by specifying them in the preset config or during installation. See Global Translation Config for more details.
If you want to specify it in the template,you can use cke5_cloud_assets() function:
{# It'll load translations for Polish, German, and French from CDN #}
{{ cke5_cloud_assets(translations: ['pl', 'de', 'fr']) }}You can fetch translations globally by specifying them in the ckeditor5:assets-mapper:install command:
php bin/console ckeditor5:assets-mapper:install --translations=pl,de,frYou can also configure translations globally in your configuration file. This is useful if you want to load translations for multiple languages at once or set a default language for the editor. Keep in mind that this configuration is only used when loading translations via CDN. If you are using self-hosted setup, translations are handled by your bundler automatically.
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
custom:
cloud:
translations: [pl, de, fr]Note: For self-hosted setups, translations are handled by your bundler automatically.
You can also provide custom translations for the editor. This is useful if you want to override existing translations or add new ones. Custom translations can be provided in the preset configuration.
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
custom:
# ...
customTranslations:
en:
Bold: Custom Bold
Italic: Custom Italic
pl:
Bold: Pogrubiony
Italic: KursywaYou can also reference custom translations directly from your editor configuration by using
an object with a $translation key. When the editor is initialized the value will be
replaced with the correct string from the loaded translation packs (including any
customTranslations defined in your preset).
This is handy if you want to keep plugin labels or other strings inside the YAML and reuse them across presets or languages.
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
with-translations:
editorType: classic
config:
toolbar: [bold, italic]
customPlugin:
label: {$translation: MyPluginLabel}
customTranslations:
en:
MyPluginLabel: My plugin
AnotherKey: Another value
pl:
MyPluginLabel: Moja wtyczkaWhen the editor runs the { $translation: 'MyPluginLabel' } entry will be automatically
resolved to the appropriate string for the current UI language, so you don’t need to
hard‑code the text in multiple places.
Paragraph-like editing mode restricts the editor's root to a single block element — by default a <p> — preventing users from inserting multiple top-level block elements (headings, lists, etc.). This is ideal for short-text fields such as article titles, captions, or descriptions: places where you want the richness of inline formatting (bold, italic, links) but a single-paragraph constraint.
The feature is enabled by passing model_element: '$inlineRoot' to the cke5_editor() or cke5_editable() Twig function. This maps the CKEditor 5 model root to the $inlineRoot schema element, which allows only inline content.
Note
Make sure your preset's toolbar and plugin list does not include block-level items (Heading, List, BlockQuote, etc.) when using model_element: '$inlineRoot', as those commands require block-level schema support that is absent in inline roots.
For single-root editor types, pass model_element: '$inlineRoot' directly to cke5_editor():
{# Paragraph-like classic editor — single <p>, inline formatting only #}
{{ cke5_editor('Article title goes here', id: 'title-editor', model_element: '$inlineRoot') }}The same parameter works with balloon and inline editor types:
{# Paragraph-like balloon editor — ideal for image captions #}
{{ cke5_editor('Image caption', editor_type: 'balloon', model_element: '$inlineRoot') }}If you reuse the inline-text pattern in many places, define a dedicated preset without block-level plugins and reference it by name:
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
inline_text:
editor_type: classic
config:
plugins: [Essentials, Bold, Italic, Link]
toolbar:
items: [bold, italic, link]{# No need to repeat the plugin list — just use the preset and restrict the root #}
{{ cke5_editor('Article title', preset: 'inline_text', model_element: '$inlineRoot') }}For the decoupled editor, pass model_element: '$inlineRoot' to the cke5_editable() function rather than to cke5_editor(). Link the editable to the editor via editor_id:
{{ cke5_editor(editor_type: 'decoupled', id: 'caption-editor') }}
{# Single editable in paragraph-like mode #}
{{ cke5_editable('main',
editor_id: 'caption-editor',
content: 'Caption text here',
model_element: '$inlineRoot',
inner_class: 'p-2 border border-gray-300'
) }}In a multiroot setup each cke5_editable() can independently opt in to paragraph-like mode. Set model_element: '$inlineRoot' only on the roots that should be restricted; leave the others without it (they default to the standard $root):
{{ cke5_editor(editor_type: 'multiroot', id: 'article-editor') }}
{# Title root: paragraph-like, only inline content allowed #}
{{ cke5_editable('title',
editor_id: 'article-editor',
content: '<p>Page Title</p>',
model_element: '$inlineRoot',
class: 'p-2 text-2xl font-bold border border-gray-300'
) }}
{# Lead root: paragraph-like, only inline content allowed #}
{{ cke5_editable('lead',
editor_id: 'article-editor',
content: '<p>Short introductory sentence.</p>',
model_element: '$inlineRoot',
class: 'p-2 italic border border-gray-300'
) }}
{# Body root: normal editing, full block content allowed #}
{{ cke5_editable('body',
editor_id: 'article-editor',
content: '<p>Full article content with headings, lists, etc.</p>',
class: 'p-2 border border-gray-300'
) }}When the editor initialises, each root whose model_element is set to '$inlineRoot' is registered under that schema element name. You can verify this at runtime via the JavaScript EditorsRegistry:
import { EditorsRegistry } from 'ckeditor5-symfony';
EditorsRegistry.the.waitFor('article-editor').then((editor) => {
// '$inlineRoot' for restricted roots, '$root' for unrestricted ones
console.log(editor.model.document.getRoot('title')?.name); // '$inlineRoot'
console.log(editor.model.document.getRoot('lead')?.name); // '$inlineRoot'
console.log(editor.model.document.getRoot('body')?.name); // '$root'
});Seamlessly integrate CKEditor 5 with Symfony Forms. The bundle provides a CKEditor5Type that facilitates easy integration. Use CKEditor5Type in your form class to render the editor.
use Mati365\CKEditor5Symfony\Form\Type\CKEditor5Type;
// ...
$builder->add('content', CKEditor5Type::class, [
'label' => 'Article Content',
'required' => true,
'attr' => ['row_attr' => ['class' => 'text-editor']],
]);Then in your template:
{{ form_row(form.content) }}The editor automatically synchronizes its content with the underlying hidden input field upon form submission.
Each editor dispatches a custom ckeditor5:change:data when any of its roots' data changes. This event includes the editor's ID, the editor instance, and the current data of all roots, allowing you to react to content changes in real time. It's safer to mount your listeners on the document.body to ensure they are registered to avoid issues with race conditions during editor initialization.
document.body.addEventListener('ckeditor5:change:data', (event) => {
const { editorId, editor, roots } = event.detail;
console.log('Editor:', editorId);
console.log('Editor instance:', editor);
console.log('Current roots data:', roots);
});To register a custom plugin, use the CustomEditorPluginsRegistry.
import { CustomEditorPluginsRegistry as Registry } from 'ckeditor5-symfony';
const unregister = Registry.the.register('MyCustomPlugin', async () => {
// It's recommended to use lazy import to
// avoid bundling ckeditor code in your application bundle.
const { Plugin } = await import('ckeditor5');
return class extends Plugin {
static get pluginName() {
return 'MyCustomPlugin';
}
init() {
console.log('MyCustomPlugin initialized');
// Custom plugin logic here
}
};
});In order to use the plugin you need to extend your config in config/packages/ckeditor5.yaml:
ckeditor5:
presets:
custom:
config:
plugins: [MyCustomPlugin, Essentials, Paragraph]
# ... other config optionsIt must be called before the editor is initialized. You can unregister the plugin later by calling the returned function:
unregister();
// or CustomEditorPluginsRegistry.the.unregister('MyCustomPlugin');If you want to de-register all registered plugins, you can use the unregisterAll method:
import { CustomEditorPluginsRegistry } from 'ckeditor5-symfony';
CustomEditorPluginsRegistry.the.unregisterAll();The context feature is designed to group multiple editor instances together, allowing them to share a common context. This is particularly useful in collaborative editing scenarios, where users can work together in real time. By sharing a context, editors can synchronize features such as comments, track changes, and presence indicators across different editor instances. This enables seamless collaboration and advanced workflows in your application.
For more information about the context feature, see the CKEditor 5 Context documentation.
Define your context in configuration:
ckeditor5:
contexts:
your_context:
config:
plugins:
- CustomContextPlugin
watchdogConfig:
crashNumberLimit: 20And use it in your template:
{# Initialize context #}
{{ cke5_context(id: 'shared-context', contextPreset: 'your_context') }}
{# Connect editors to context #}
{{ cke5_editor(content: 'Child A', contextId: 'shared-context') }}
{{ cke5_editor(content: 'Child B', contextId: 'shared-context') }}Voila!
Define your custom translations in the configuration:
ckeditor5:
contexts:
custom:
# ...
customTranslations:
en:
Bold: Custom Bold
Italic: Custom Italic
pl:
Bold: Pogrubiony
Italic: KursywaThese translations will be used in the context's editors, overriding the default translations. They are available through locale.t plugin in every context plugin.
The package provides two registries: EditorsRegistry and ContextsRegistry. They allow you to watch for changes in registered editors and contexts, get instances directly, or execute logic when a specific editor or context appears.
-
mountEffect(id, callback)— executes logic whenever the editor is initialized or restarted. This is the recommended way to implement integrations that must be re-initialized throughout the editor's lifecycle.import { EditorsRegistry } from 'ckeditor5-symfony'; EditorsRegistry.the.mountEffect('editor1', (editor) => { const watcher = () => { console.info('Changed data:', editor.getData()); }; editor.model.document.on('change:data', watcher); // Cleanup: This will be executed when the editor is unmounted. return () => { editor.model.document.off('change:data', watcher); }; });
-
watch(callback)— react whenever registry state changes.import { EditorsRegistry } from 'ckeditor5-symfony'; const unregisterWatcher = EditorsRegistry.the.watch((editors) => { console.log('Registered editors changed:', editors); }); // Later, you can unregister the watcher unregisterWatcher();
-
waitFor(id)— get the instance directly. If it is already registered, the promise resolves immediately.import { EditorsRegistry } from 'ckeditor5-symfony'; EditorsRegistry.the.waitFor('editor1').then((editor) => { console.log('Editor "editor1" is registered:', editor); }); // ... init editor somewhere later
-
execute(id, callback)— run logic immediately if the instance already exists, or later when it appears.import { EditorsRegistry } from 'ckeditor5-symfony'; EditorsRegistry.the.execute('editor1', (editor) => { console.log('Current data:', editor.getData()); });
-
The same methods are available on
ContextsRegistryfor shared contexts:import { ContextsRegistry } from 'ckeditor5-symfony'; ContextsRegistry.the.waitFor('shared-context').then((watchdog) => { console.log('Context is ready:', watchdog.context); }); ContextsRegistry.the.execute('shared-context', (watchdog) => { console.log('Context state:', watchdog.state); });
The ckeditor5:assets-mapper:install command supports the following options:
php bin/console ckeditor5:assets-mapper:install --help
Description:
Configure CKEditor5 assets in importmap.php, update base template, and download CKEditor to assets/vendor for cloud or NPM distribution
Usage:
ckeditor5:assets-mapper:install [options]
Options:
--distribution=DISTRIBUTION Distribution type: cloud or npm [default: "npm"]
--importmap-path=IMPORTMAP-PATH Path to importmap.php file [default: "importmap.php"]
--editor-version=EDITOR-VERSION CKEditor version [default: "48.2.0"]
--translations=TRANSLATIONS Comma-separated list of translations [default: "en"]
--template-path=TEMPLATE-PATH Path to base template file [default: "templates/base.html.twig"]
--js-path=JS-PATH Path to main JS file [default: "assets/app.js"]
--css-path=CSS-PATH Path to main CSS file [default: "assets/styles/app.css"]
--ckbox-version[=CKBOX-VERSION] CKBox version
--ckbox-theme[=CKBOX-THEME] CKBox theme (light or dark)
--premium Include premium features
--skip-template-update Skip updating the Twig template
--skip-composer-update Skip updating composer.json
--skip-css-update Skip updating CSS imports
--skip-js-update Skip updating JS imports
--skip-all-updates Alias: skip all update steps (template, composer, CSS, JS) — used in auto-scripts to prevent re-runs
-h, --help Display help for the given command. When no command is given display help for the list command
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi|--no-ansi Force (or disable --no-ansi) ANSI output
-n, --no-interaction Do not ask any interactive question
-e, --env=ENV The Environment name. [default: "1"]
--no-debug Switch off debug mode.
--profile Enables profiling (requires debug).
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debugTo start the development environment, run:
pnpm run devThe playground app will be available at http://localhost:8000.
The project includes comprehensive PHP unit tests with 100% code coverage requirement:
# Run all tests
composer test
# Run tests with coverage report (requires pcov)
composer test:coverageIf you're looking for similar stuff, check these out:
-
ckeditor5-livewire Effortless CKEditor 5 integration for Laravel Livewire. Supports dynamic content, localization, and custom builds with minimal setup.
-
ckeditor5-phoenix Seamless CKEditor 5 integration for Phoenix Framework. Plug & play support for LiveView forms with dynamic content, localization, and custom builds.
-
ckeditor5-rails Smooth CKEditor 5 integration for Ruby on Rails. Works with standard forms, Turbo, and Hotwire. Easy setup, custom builds, and localization support.
CKEditor® is a trademark of CKSource Holding sp. z o.o. All rights reserved. For more information about the license of CKEditor® please visit CKEditor's licensing page.
This package is not owned by CKSource and does not use the CKEditor® trademark for commercial purposes. It should not be associated with or considered an official CKSource product.
This project is licensed under the terms of the MIT LICENSE.
This project injects CKEditor 5 which is licensed under the terms of GNU General Public License Version 2 or later. For more information about CKEditor 5 licensing, please see their official documentation.






