Text Formats and CKEditor 5
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every rich-text field in Drupal (the body of a news article, the description of an exhibition, a block’s text) stores two values: the text and the machine name of a text format. The format decides two things: how the stored text is processed before a visitor sees it (the filters), and which editor, if any, an author gets while typing it (the editor, in practice CKEditor 5). This page explains both halves, how they interact through the "allowed tags" model, the security consequences of each choice, and how to extend them in code. It was checked against the official documentation on 2026-10-30 and is written for Drupal 11.4.x (PHP 8.3 or later, Drush 13 or 14). The running example is the Meridian Museum: editors write exhibition descriptions, curators need captions and object numbers that link to the collection, and anonymous visitors post comments that must never carry markup.
The two halves: filters and editors
A text format is a configuration entity (filter.format.<machine_name>). It holds a name, a weight, the roles that may use
it, and an ordered list of enabled filter plugins with their settings. An optional second configuration entity,
editor.editor.<machine_name>, attaches an editor to the format and stores the editor’s own settings (for CKEditor 5: the
toolbar items and the per-plugin options). The Filter and Editor modules provide the entities; the CKEditor 5 module
provides the editor. A format without an editor.editor.* entity is a plain <textarea>.
The original text is stored unchanged in the database. Filters run when the text is rendered, not when it is saved. Two
consequences follow. Tightening a format (for example removing <iframe> from its allowed tags) immediately protects old
content as well, without editing a single node. And the rendered result can be cached, which is why the processed_text
render element attaches the format’s cache tag so that changing the format invalidates the cached output.
The formats the Standard profile creates
The Standard installation profile creates three formats. The values below come from the profile’s shipped configuration in the 11.x branch; individual tag lists change between minor versions, so export your own site’s configuration to see its exact state.
| Format | Default roles | Editor and filters | Intended use |
|---|---|---|---|
Basic HTML |
Authenticated users (permission |
CKEditor 5 with a short toolbar (bold, italic, link, lists, block quote, image, headings 2 to 6, code, source editing).
Filters: |
Members and staff who write ordinary content. This is the format a new site should give to every logged-in role. |
Restricted HTML |
Anonymous users (permission |
No editor, a plain textarea. Filters: |
Untrusted visitors, such as anonymous comments. It is not meant for authoring pages. |
Full HTML |
Administrator role |
CKEditor 5 with tables, horizontal rules, code blocks, strikethrough, super- and subscript. Filters: no tag restriction,
only |
Trusted site builders only. Its security cost is covered below. |
Two more things matter in practice. First, the format list has a fallback format (Plain text, which escapes all markup
and cannot be disabled) used when a stored format no longer exists or the viewer may not use it. Second, since Drupal 11.4
the Standard profile no longer creates the Article and Page content types, so the formats are attached to whatever
fields you create yourself. Older books such as Drupal 10 Development Cookbook (2023) and tutorials that start from an
installed Article type must first create a content type, for example the Meridian exhibition type described in
Content Types and Fields.
Configuring a format in the administration UI
The list of formats is at Administration > Configuration > Content authoring > Text formats and editors
(/admin/config/content/formats), guarded by the permission administer filters. The Add text format button leads to
/admin/config/content/formats/add. A format form has these parts:
-
Name and Roles. Ticking a role grants it the permission
use text format <machine_name>; this is the same permission you can assign on the permissions page. Roles without it can still see content stored in the format, but they cannot edit it. See Users, Roles and Permissions. -
Text editor, a select list that offers "None" and "CKEditor 5". Changing it reloads the form and the editor configuration panel appears.
-
Toolbar configuration, CKEditor 5 plugin settings, Image uploads and Source editing (CKEditor 5 section, below).
-
Enabled filters, a checkbox list of every filter plugin, with a settings panel for each enabled filter under Filter settings.
-
Filter processing order, a drag list that sets each filter’s weight.
The User Guide chapter on this topic (Concept: Text Formats and Editors and Configuring Text Formats and Editors, sections 6.15 and 6.16) still speaks of "the CKEditor module" and an editor labelled "CKEditor". The module is now called CKEditor 5 and the editor option reads "CKEditor 5"; the steps are otherwise the same.
Filters in core
Each filter is a plugin of type filter with a type that tells the system what it does. The type is a constant on
\Drupal\filter\Plugin\FilterInterface:
| Constant | Value | Meaning |
|---|---|---|
|
0 |
Turns a non-HTML markup language into HTML (for example |
|
1 |
Restricts tags and attributes to prevent XSS ( |
|
2 |
Transforms that could be undone by a reverse filter ( |
|
3 |
One-way transformations, such as turning an object number into a link. |
The filters you meet most often are:
-
filter_html("Limit allowed HTML tags and correct faulty HTML"): the only core filter that strips tags and attributes it has not been told about. It has the settingsallowed_html,filter_html_help(show the tag list under the field) andfilter_html_nofollow(addrel="nofollow"to links). -
filter_html_escape: shows any HTML as plain text, used by the Plain text format. -
filter_htmlcorrector: repairs unclosed or misnested tags. It should run last so that earlier filters cannot leave broken markup behind. -
filter_html_image_secure: restricts images to local ones, so a visitor’s browser is not made to load a remote tracking pixel. -
filter_captionandfilter_align: turn thedata-captionanddata-alignattributes written by CKEditor 5 into a<figure>and an alignment class. -
filter_image_lazy_load: addsloading="lazy"to images. -
editor_file_reference: resolvesdata-entity-uuidon uploaded images to the current file, so an image keeps working if its file URL changes. -
media_embed: renders<drupal-media>elements written by the Media Library button; see Media and the Media Library. -
filter_urlandfilter_autop: convenience formatting for plain-textarea formats.
Order matters. Filters run from the lowest weight to the highest. filter_html is deliberately placed at weight -10 so
that it sees the author’s raw markup first. A filter that creates links or images from tokens normally runs after it, so the
restrictor judges only what the author wrote, and before filter_htmlcorrector. A common mistake is dragging
filter_html below a transformation filter, which lets author-supplied markup reach that filter unchecked.
The allowed-tags model
The most important idea in CKEditor 5 for Drupal is that the editor never decides what is safe; the format does. A format
that contains the filter_html filter has an allowed_html setting, a string in the syntax
<tag attribute attribute="value1 value2"> <tag2>. CKEditor 5 for Drupal works from that list:
-
Every CKEditor 5 plugin declares in its definition which elements and attributes it produces (the
elementskey, see the plugin API below). -
When you enable a toolbar item, the CKEditor 5 module computes the union of the elements declared by the enabled plugins and writes it into the
allowed_htmlsetting offilter_html. While CKEditor 5 is the editor of a format withfilter_html, that field is generated and cannot be edited by hand. -
The editor is then configured with exactly that set, so the author cannot produce markup the filter would later strip, which would otherwise make "what you see" differ from "what you get".
-
Markup the toolbar cannot express (an
idon a heading, aclasson a<div>) is added in the Source editing panel under Manually editable HTML tags. Those tags are merged into the same allowed list.
If the format has no HTML-restricting filter (Full HTML), there is nothing to compute: the module enables the plugins that make CKEditor 5 keep arbitrary markup (the wildcard and arbitrary HTML support plugins, which wrap the General HTML Support feature of CKEditor 5) and the editor accepts any tag. Whatever the author types is stored and, because no filter restricts it, rendered.
This is validated. Saving a format whose configuration cannot be satisfied (for example a toolbar item whose plugin needs
<img> while filter_html is configured without it, or a "Manually editable" tag that is already provided by another
plugin) is rejected with a message, because the editor settings schema uses constraints such as
SourceEditingRedundantTags. Markup that the new configuration does not support may be altered the next time existing content is opened in the
editor, because CKEditor 5 re-serialises the markup it loads.
CKEditor 5 toolbar configuration
In the editor configuration panel, the Toolbar configuration is a drag-and-drop area with Available buttons and
Active toolbar. A vertical divider (stored as the item '|') groups buttons. The same configuration is stored in
editor.editor.<format>, under settings.toolbar.items, as an ordered list of toolbar item names. The item names are the
keys under drupal.toolbar_items in the plugin definitions (bold, italic, link, bulletedList, numberedList,
blockQuote, drupalInsertImage, heading, code, codeBlock, insertTable, horizontalLine, style,
sourceEditing, drupalMedia, specialCharacters, alignment, removeFormat, undo, redo and more).
Enabling a button enables its plugin. Some plugins have no button of their own and are switched on by conditions on other
plugins or filters: the image caption plugin appears only when the filter_caption filter is enabled, image alignment
only with filter_align, the media plugins only with media_embed, and the "Linked image" plugin only when both the
link and image plugins are on. Plugins with settings add a panel under CKEditor 5 plugin settings; for example,
Headings chooses which of heading 2 to 6 appear (Heading 1 is not offered in the shipped formats; the page title is normally the <h1>), List switches reversed lists, start numbers and list styles on, and Code block lists the programming
languages offered.
Reading and exporting the configuration
# Show the toolbar of the Basic HTML format
ddev drush config:get editor.editor.basic_html settings.toolbar.items
# Show the generated allowed tags
ddev drush config:get filter.format.basic_html filters.filter_html.settings.allowed_html
# Export so that the format is versioned with the rest of the site
ddev drush config:export --yes
Source: Drush: config:get
The same configuration, for a Meridian-specific format used by curators, looks like this when exported. This is what
code review and config:import operate on, and it is the best way to understand the model:
# config/sync/editor.editor.meridian_curator.yml
langcode: en
status: true
dependencies:
config:
- filter.format.meridian_curator
module:
- ckeditor5
format: meridian_curator
editor: ckeditor5
settings:
toolbar:
items:
- bold
- italic
- '|'
- link
- '|'
- bulletedList
- numberedList
- '|'
- blockQuote
- drupalInsertImage
- '|'
- heading
- style
- '|'
- sourceEditing
plugins:
ckeditor5_heading:
enabled_headings:
- heading2
- heading3
ckeditor5_imageResize:
allow_resize: true
ckeditor5_list:
properties:
reversed: false
startIndex: true
styles: false
multiBlock: true
ckeditor5_sourceEditing:
allowed_tags:
- '<cite>'
- '<h2 id>'
- '<h3 id>'
ckeditor5_style:
styles:
-
label: 'Object label'
element: '<p class="meridian-object-label">'
-
label: 'Gallery note'
element: '<p class="meridian-gallery-note">'
-
label: 'Highlighted term'
element: '<span class="meridian-term">'
image_upload:
status: true
scheme: public
directory: inline-images
max_size: null
max_dimensions:
width: null
height: null
The matching format entity declares the filters and the generated tag list. The allowed_html string is not typed by
hand; when the format is saved through the UI the CKEditor 5 module recomputes it, and a configuration import should
carry the same value, otherwise the next save through the UI changes it.
# config/sync/filter.format.meridian_curator.yml
langcode: en
status: true
dependencies:
module:
- editor
name: 'Meridian curator'
format: meridian_curator
weight: 0
filters:
editor_file_reference:
id: editor_file_reference
provider: editor
status: true
weight: 11
settings: { }
filter_align:
id: filter_align
provider: filter
status: true
weight: 7
settings: { }
filter_caption:
id: filter_caption
provider: filter
status: true
weight: 8
settings: { }
filter_html:
id: filter_html
provider: filter
status: true
weight: -10
settings:
allowed_html: '<br> <p class="meridian-object-label meridian-gallery-note"> <span class="meridian-term"> <cite> <h2 id> <h3 id> <a href hreflang> <blockquote cite> <ul type> <ol start type> <li> <strong> <em> <img src alt data-entity-uuid data-entity-type height width data-caption data-align>'
filter_html_help: false
filter_html_nofollow: false
filter_htmlcorrector:
id: filter_htmlcorrector
provider: filter
status: true
weight: 10
settings: { }
filter_image_lazy_load:
id: filter_image_lazy_load
provider: filter
status: true
weight: 15
settings: { }
Source: Configuration API
The roles that may use the format are not part of an exported format file (the roles key in a shipped install file is
applied as permissions when the profile installs). The role-to-format link lives on the role, as the permission use text format meridian_curator, so the user.role.curator.yml export must contain it. A
frequent deployment error is exporting the format on one environment but forgetting the permission, which leaves the
curators with a format they can read but not select. See
Configuration Management.
Source editing and its restrictions
The Source button opens a view of the HTML. In a format with filter_html it is the way to allow markup that has no
toolbar button, and it is also the most common place to create a security or support problem:
-
The Manually editable HTML tags field takes tags and attributes in the same syntax as
allowed_html. Entries that a plugin already allows are redundant and rejected by validation; allow<h2 id>here only because the Headings plugin allows<h2>withoutid. -
Typing a tag that is not allowed in the source view does not produce an error; CKEditor 5 silently removes what it does not know when the author switches back to the visual view, and
filter_htmlwould remove it again on output. A curator who "loses" a<div class="timeline">has hit this. -
A wildcard such as
<* lang>allows an attribute on any tag. The core plugins "Globallangattribute" and "Globaldirattribute" use this to let editors mark text as another language or direction without listing the attribute on each tag. -
<script>,<style>and event attributes such asonclickare never a good fit here.filter_htmlremoves them from a restricted format regardless of what the editor accepted, so the only way to store them is a format without the restrictor. -
Giving Source to an untrusted role is not a security decision when
filter_htmlis on (the filter still runs), but it is when it is off: that role can then write raw HTML.
Pasting from Word and similar sources is handled by the Paste From Office plugin (enabled by default and invisible), which normalises the pasted markup before the restrictions are applied. Editors still see the result of the restrictions only after pasting, so test pasted content with the real format.
The Style plugin
The Style button (toolbar item style) lets an editor apply a CSS class to an element from a labelled drop-down. It is
the supported way to offer "Object label" or "Gallery note" without opening the source view. Each style is a pair of a
label and an element string with exactly one attribute, class, holding one or more classes. The configuration is shown
above in the ckeditor5_style.styles list; in the UI each row has a "Label" field and a "Tag and CSS classes" field, for
example <p class="meridian-object-label">.
Remember the two sides of a style. The allowed tags side is automatic: adding a style adds the element and class to
the generated allowed_html (the plugin declares <$any-html5-element class> and the configured styles narrow it down).
The appearance side is not: the class must be styled by CSS, both on the public site and, for the editing experience,
inside the editor. A theme lists stylesheets for the editing area with the key ckeditor5-stylesheets in its
*.info.yml:
# themes/custom/meridian/meridian.info.yml
name: Meridian
type: theme
base theme: false
description: 'Theme for the Meridian Museum'
core_version_requirement: ^10.3 || ^11
ckeditor5-stylesheets:
- css/ckeditor5.css
Treat that key with care in versions to come. In 11.4 the internal helper function that reads it,
_ckeditor5_theme_css(), was deprecated with no replacement and is scheduled for removal in 12.0.0; the change record
that lists the deprecation does not say what happens to the ckeditor5-stylesheets key itself, so check the 12.x
release notes before relying on it. Books written for Drupal 10.1, such as Modernizing Drupal 10 Theme Development
(2023), which teaches CKEditor 5 styles, use the key as above and are otherwise still accurate for the Style plugin. They
predate the 11.x deprecation around it, and their theme examples extend base themes that Drupal 12 removes; build
themes from Starterkit instead.
Another operational detail: when you change a style’s label or classes, existing content keeps its markup, but the
editor’s drop-down no longer highlights an applied style whose class has been removed from the list, and filter_html no
longer permits that class on output. Remove styles only when no content uses them, or run a database search first.
Links
The Link plugin (ckeditor5_link) gives the link balloon: a URL field and, if configured, extra toggles called decorators.
Core configures the default protocol as https://, so an author who types
meridian.example/visit gets an absolute link rather than a relative one. The plugin allows <a> and <a href>.
Practical points:
-
The link balloon accepts internal paths (
/visit, which are stored as typed and are not kept in step with path alias changes unless you use the entity link plugins), external URLs,mailto:andtel:links. Allowed protocols are filtered on output by thefilter_protocolscontainer parameter; unusual schemes such asjavascript:are removed. -
Linked image and Linked media plugins appear automatically when link and image (or Media) are all enabled, so images can be wrapped in links.
-
Entity links (a plugin shown only when the matching filter is enabled) turn a typed title into a link to content that keeps working if the target’s alias changes.
-
The "Open in new window" decorator is not offered by default. The CKEditor 5 module rejects the automatic link decorators (
mode: automatic) andaddTargetToExternalLinks; the official guidance is to use Drupal’s filter system for that kind of rewrite, and a manual decorator is the route that is supported, shown below. -
A link that opens a new tab needs
rel="noopener".target="_blank"in modern browsers impliesnoopener, but it is a good habit to confirm that in your support matrix, andfilter_html_nofollowonly addsnofollow.
Extending text formats in code
Changing a CKEditor 5 plugin with a hook
The CKEditor 5 JavaScript plugin settings come from the ckeditor5.config key of the Drupal plugin definition. To change
them, a module implements hook_ckeditor5_plugin_info_alter(), which runs after all plugin definitions are collected. It
runs only when the cache is rebuilt, so clear the cache after each change. The example below adds a manual "Open in new
window" decorator to the link plugin, as an object-oriented hook (Drupal 11.1 and later). In Drupal 10.x write the same
body as a procedural meridian_collection_ckeditor5_plugin_info_alter() function in meridian_collection.module, or keep
both with #[LegacyHook]; see Hooks and Object-Oriented Hooks.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition;
/**
* CKEditor 5 customisations for the Meridian Museum site.
*/
final class EditorHooks {
use StringTranslationTrait;
/**
* Adds an "Open in new window" decorator to the link balloon.
*
* @param array<string, \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition> $plugin_definitions
* The plugin definitions, keyed by plugin ID.
*/
#[Hook('ckeditor5_plugin_info_alter')]
public function pluginInfoAlter(array &$plugin_definitions): void {
$definition = $plugin_definitions['ckeditor5_link']->toArray();
$definition['ckeditor5']['config']['link']['decorators'][] = [
'mode' => 'manual',
'label' => (string) $this->t('Open in new window'),
'attributes' => [
'target' => '_blank',
'rel' => 'noopener',
],
];
$plugin_definitions['ckeditor5_link'] = new CKEditor5PluginDefinition($definition);
}
}
The same page notes that some settings do not belong to a plugin; those are changed with hook_editor_js_settings_alter()
from the Editor module. Because target and rel are attributes on <a>, they must also be allowed by the format.
Add <a target rel> under Manually editable HTML tags (or in the Source editing allowed_tags list) or filter_html
will remove them on output.
The plugin definition format
A module can add its own toolbar button (for example a "Cite object" button) by defining a CKEditor 5 plugin in
<module>.ckeditor5.yml or with a PHP attribute, but not both for the same plugin. The two top-level keys split the
definition into what CKEditor 5 needs and what Drupal needs:
-
provider: a module the plugin depends on. It is not loaded if that module is not installed. -
ckeditor5.plugins: the JavaScript plugin classes to load, written<package>.<Class>. -
ckeditor5.config: the CKEditor 5 configuration the plugin needs. -
drupal.label: the human-readable name (required). -
drupal.library: the asset library that holds the compiled JavaScript.drupal.admin_libraryis loaded only on the format administration page. -
drupal.class: an optional PHP class implementingCKEditor5PluginInterfacefor dynamic configuration or a settings form in the editor panel. A configurable plugin with non-empty default configuration also needs a configuration schema. -
drupal.elements: the elements and attributes the plugin generates, inallowed_htmlsyntax plus the special keywords<$text-container>and<$any-html5-element>. Usefalseif the plugin generates no markup of its own. An attribute entry alone does not allow its tag, so list both. -
drupal.toolbar_items: the buttons the plugin provides, each with a label. -
drupal.conditions: when to load the plugin. The allowed condition types aretoolbarItem,filter,imageUploadStatus,requiresConfigurationandplugins.
JavaScript plugins are compiled with the build tooling that Drupal core uses (yarn build:ckeditor5 in core for core
plugins; contributed modules ship their own build). To see which CKEditor 5 version a running site uses, enter
CKEDITOR_VERSION in the browser console. The 11.x branch pins the CKEditor 5 packages at the 47.x series.
Source: CKEditor 5 development
A custom filter plugin
Filters are plugins with a PHP attribute (since Drupal 10.2; annotations such as @Filter still work but are on the
deprecation path). The Meridian curators write object numbers such as MRD-1042 in prose, and the site should turn each
into a link to the collection entry. This is a transformation that cannot be reversed from the output, so the type is
TYPE_TRANSFORM_IRREVERSIBLE. The filter works on the parsed DOM instead of a regular expression over the whole string,
so it never touches text inside an attribute or inside a link that already exists.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\Filter;
use Drupal\Component\Utility\Html;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\filter\Attribute\Filter;
use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;
use Drupal\filter\Plugin\FilterInterface;
/**
* Links accession numbers such as MRD-1042 to the collection entry.
*/
#[Filter(
id: 'meridian_accession_link',
title: new TranslatableMarkup('Link Meridian accession numbers'),
type: FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE,
description: new TranslatableMarkup('Turns object numbers like MRD-1042 into links to the collection.'),
weight: 5,
)]
final class AccessionLink extends FilterBase {
private const PATTERN = '/\b(MRD-\d{4,6})\b/';
/**
* {@inheritdoc}
*/
public function process($text, $langcode): FilterProcessResult {
$result = new FilterProcessResult($text);
if (!preg_match(self::PATTERN, $text)) {
return $result;
}
$dom = Html::load($text);
$xpath = new \DOMXPath($dom);
// Only text nodes that are not already inside a link.
$nodes = $xpath->query('//text()[not(ancestor::a)]');
foreach (iterator_to_array($nodes) as $node) {
$parts = preg_split(self::PATTERN, $node->nodeValue, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === FALSE || count($parts) < 2) {
continue;
}
$fragment = $dom->createDocumentFragment();
foreach ($parts as $index => $part) {
if ($index % 2 === 1) {
$link = $dom->createElement('a', $part);
$url = Url::fromRoute('meridian_collection.object_by_accession', [
'accession_number' => $part,
]);
$link->setAttribute('href', $url->toString());
$fragment->appendChild($link);
}
elseif ($part !== '') {
$fragment->appendChild($dom->createTextNode($part));
}
}
$node->parentNode->replaceChild($fragment, $node);
}
$result->setProcessedText(Html::serialize($dom));
// The links depend on the route and on routing configuration.
$result->addCacheTags(['config:meridian_collection.settings']);
return $result;
}
/**
* {@inheritdoc}
*/
public function tips($long = FALSE): string {
return (string) $this->t('Object numbers such as MRD-1042 become links to the collection.');
}
}
Source: Filter API
Notes on this example. The filter has to run after filter_html (weight -10) and before filter_htmlcorrector
(weight 10), hence weight 5. The new <a href> is already permitted in Basic HTML, so the restrictor needs no change;
a filter that generates tags that are not on the allowed list must have those tags added to the format when it
runs before filter_html. FilterBase::tips() is shown to
authors under the field. The cache tag declares that the output depends on configuration; use more specific tags and
contexts, never max-age: 0, for anything that varies. To enable it, tick Link Meridian accession numbers on the
format. In filter.format.<id>.yml the filter entry looks like the ones above, with provider: meridian_collection.
The route meridian_collection.object_by_accession is defined in the module’s routing file; see
Routing and Controllers.
Rendering and form elements
Code that outputs stored text must not print it directly. Use the processed_text render element, which applies the
filters, adds the format’s cache tags and respects the viewer’s access; Twig templates for fields do this already.
check_markup() still works but is deprecated in 11.4 and removed from 13.0.0, with no direct replacement; the
guidance is to return a render array and avoid flattening the result into markup, so cacheability metadata is kept.
Likewise, filter_formats(), filter_default_format() and filter_fallback_format() are deprecated wrappers in 11.4;
inject Drupal\filter\FilterFormatRepositoryInterface instead (methods such as getAllFormats(),
getFormatsForAccount(), getFormatsByRole(), getDefaultFormat() and getFallbackFormatId()). Older books and
tutorials, including Drupal 10 Development Cookbook (2023) era code, use the procedural functions.
<?php
// Displaying stored text safely.
$build['description'] = [
'#type' => 'processed_text',
'#text' => $exhibition->get('field_summary')->value,
'#format' => $exhibition->get('field_summary')->format,
];
// A settings-form field that restricts authors to two formats.
$form['visit_notice'] = [
'#type' => 'text_format',
'#title' => $this->t('Visitor notice'),
'#default_value' => $config->get('visit_notice.value'),
'#format' => $config->get('visit_notice.format') ?? 'basic_html',
'#allowed_formats' => ['basic_html', 'meridian_curator'],
];
Source: ProcessedText class
Security consequences of Full HTML
A text format without a restricting filter is a trust decision, because the browser executes whatever is stored. Full HTML therefore lets its author:
-
insert
<script>elements and inline event handlers, which run in the browser of every visitor, including administrators who view the page. This is stored cross-site scripting (XSS), and an editor with a stolen account or a malicious editor can take over an administrator session this way; -
embed
<iframe>and<form>elements that load third-party content or mimic a login page; -
reference images from other servers, which tracks visitors.
The rules that follow:
-
Grant
use text format full_htmlonly to roles whose members you would trust with the administrator’s session. On Meridian, only the Administrator role has it; Curators, Content Editors and Reviewers use Basic HTML or the custommeridian_curatorformat. -
Never give an anonymous or "Member" role a format whose filters do not include the restrictor. Restricted HTML exists for that case. A visitor-facing form, such as a comment or the contact form, should use a format with no editor and with
filter_html, or the plain-text fallback. -
The editor is not a security control. CKEditor 5 limits what is typed in the browser, but a request can be crafted with any HTML, so only
filter_htmlon the server protects visitors. The HTML restrictor must stay enabled in every format that untrusted users may use, and it must be the first filter. -
Do not add an unsafe attribute list to
allowed_htmlto make something "just work". Allowingstyleoron*attributes, or allowing<a href>without the protocol filter, reintroduces XSS. -
Review every contributed filter and editor plugin like code that can run in a visitor’s browser.
-
Consider the Security Kit contributed module or a Content Security Policy for defence in depth. The hardening checklist is in Security Hardening.
A common pitfall is relying on the role of the author rather than the format: the viewer needs no permission to see content in Full HTML, so one careless Full HTML paragraph written by an administrator is enough to attack all visitors if that administrator’s account is later compromised. Mark which fields allow Full HTML and review them when roles change.
CKEditor 4 was removed in Drupal 10.0
CKEditor 4 is not part of Drupal 10 or later. The change record "CKEditor 4 is removed from Drupal Core in 10.0.0" (published 2022-09-09) states that it was removed from core. The path was:
-
Drupal 9.3 introduced CKEditor 5 as experimental, and 9.5 made it stable and deprecated the CKEditor 4 module.
-
Drupal 10.0.0 removed CKEditor 4. Sites that still need it depend on the contributed CKEditor module, which is a separate project and is not maintained as part of core.
-
Updating from Drupal 9 requires text formats to be switched to CKEditor 5 first. A 9.4.4 change made the upgrade no longer require CKEditor 4 to be present, so the conversion can run after the module has gone, but the supported path is to switch every format on Drupal 9.5 before moving to 10.
The same release removed Color, Quick Edit, RDF, Aggregator and HAL from core, and 11.0 removed more (Actions UI, Activity Tracker, Book, Forum, Statistics and Tour). Before upgrading, install the contributed versions of the removed modules you use and do not uninstall them first; the full list and replacements are in Removed Core Modules and Replacements and the upgrade steps in Upgrading Drupal 10 to 11.
Practical consequences when you inherit an old site or follow an old tutorial:
-
Instructions that mention the
ckeditor.settings, the CKEditor 4 "Advanced"config.js, theCKEDITOR.configobject or thehook_ckeditor_plugin_info_alter()hook belong to CKEditor 4 and do not work. Use CKEditor 5 plugin definitions as shown above. -
CKEditor 4 buttons, such as "Styles" combo with a
Name|classsyntax, are replaced by the Style plugin and its label and class list. -
Contributed modules that offered CKEditor 4 plugins must provide a CKEditor 5 version; the guidance for module maintainers is in the CKEditor 5 module documentation pages on upgrading modules that extend CKEditor 4.
-
The version of CKEditor 5 is tied to Drupal, not to your site. Do not replace its files in
core/assets/vendor; core updates ship the matching build.
Common errors and pitfalls
-
Changes to the toolbar do nothing on the site. The toolbar changes are saved in configuration, but cached output and, for plugin definitions, the plugin discovery cache must be rebuilt. Run
ddev drush cache:rebuild, hard-reload the browser, and check that theeditor.editor.<format>import ran on the target environment. -
A tag vanishes after saving. It is not in the format’s allowed list. Add it under Manually editable HTML tags, or enable the toolbar item that produces it. This is the first thing to check, before suspecting a bug.
-
The Allowed HTML tags field cannot be edited. This is intended while CKEditor 5 manages a format with
filter_html; change the toolbar or the source-editing list instead. -
Switching a format loses markup. Changing the format selector on a field that already has content re-runs the editor on existing markup. Test on a copy and keep a revision (Content Moderation or revisions on the content type).
-
Curators cannot choose the format. The role lacks
use text format <id>, or the permission was not exported. -
Pasted content has odd classes or inline styles. Basic HTML strips them on output, but they stay in the stored value. Run a clean-up migration if the stored value matters.
-
The
data-entity-uuidimage breaks. Images embedded through CKEditor 5 neededitor_file_referenceenabled and their attributes allowed. Do not disable the filter on a format that has inline images. -
Editing a Full HTML field is refused. The user lacks permission to use that format; the field is displayed but the user cannot change it. Give the role access or change the stored format on purpose.
-
Hook and API samples from older material fail on 11.4. Look for
check_markup(),filter_formats()and annotation syntax such as@Filter; migrate toprocessed_text, the format repository and#[Filter].
Version summary
| Version | What changed |
|---|---|
9.3 / 9.5 |
CKEditor 5 introduced as experimental, then stable; CKEditor 4 deprecated in 9.5. |
10.0 |
CKEditor 4 removed from core. CKEditor 5 is the only core editor. Color, Quick Edit, RDF, Aggregator and HAL removed. |
10.2 |
Plugins (including filters) use PHP attributes such as |
11.0 |
Further modules removed (Actions UI, Activity Tracker, Book, Forum, Statistics, Tour). |
11.1 |
Object-oriented hooks with |
11.2 |
Older icon names in the |
11.4 |
|
12.0 (planned week of 2026-12-07) |
Removes the deprecated items above. The Claro, Olivero and Stable 9 themes are removed, so an editor stylesheet declared in a custom theme should not rely on them. |
Drupal 10 reaches end of life on 2026-12-09, so new sites should start on 11.x. For the release calendar and support windows see Versions, Release Cycle and Support.