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.

flowchart LR A[Editor types in CKEditor 5] --> B[Browser sends HTML and the format id] B --> S[(Field storage: value plus format)] S --> R[Render: processed_text element] R --> F1[filter_html weight -10] F1 --> F2[filter_align, filter_caption] F2 --> F3[filter_image_lazy_load, editor_file_reference] F3 --> F4[filter_htmlcorrector] F4 --> O[Safe HTML in the page] CFG[(filter.format.basic_html)] -. enabled filters and weights .-> R ED[(editor.editor.basic_html)] -. toolbar and plugins .-> A ED -. generates allowed tags .-> CFG

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 use text format basic_html)

CKEditor 5 with a short toolbar (bold, italic, link, lists, block quote, image, headings 2 to 6, code, source editing). Filters: filter_html (limit allowed tags), filter_align, filter_caption, filter_html_image_secure, filter_image_lazy_load and editor_file_reference.

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 use text format restricted_html)

No editor, a plain textarea. Filters: filter_html with a small tag list and visible help text, filter_autop (blank lines become paragraphs) and filter_url (bare web addresses become links, shortened to 72 characters).

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 filter_align, filter_caption, filter_htmlcorrector, filter_image_lazy_load and editor_file_reference.

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

TYPE_MARKUP_LANGUAGE

0

Turns a non-HTML markup language into HTML (for example filter_autop, filter_url).

TYPE_HTML_RESTRICTOR

1

Restricts tags and attributes to prevent XSS (filter_html, filter_html_escape).

TYPE_TRANSFORM_REVERSIBLE

2

Transforms that could be undone by a reverse filter (filter_caption, filter_align).

TYPE_TRANSFORM_IRREVERSIBLE

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 settings allowed_html, filter_html_help (show the tag list under the field) and filter_html_nofollow (add rel="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_caption and filter_align: turn the data-caption and data-align attributes written by CKEditor 5 into a <figure> and an alignment class.

  • filter_image_lazy_load: adds loading="lazy" to images.

  • editor_file_reference: resolves data-entity-uuid on 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_url and filter_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:

  1. Every CKEditor 5 plugin declares in its definition which elements and attributes it produces (the elements key, see the plugin API below).

  2. 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_html setting of filter_html. While CKEditor 5 is the editor of a format with filter_html, that field is generated and cannot be edited by hand.

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

  4. Markup the toolbar cannot express (an id on a heading, a class on 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.

flowchart TD T[Toolbar item enabled] --> P[Plugin definition: elements] S[Source editing: manually editable tags] --> U P --> U[Union of allowed elements] U --> A[filter_html allowed_html generated] U --> C[CKEditor 5 General HTML Support configuration] A --> OUT[Server-side filter at render time] C --> IN[Client-side editor model] N[Format without filter_html] --> W[Wildcard HTML support: any markup kept]

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

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: {  }

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> without id.

  • 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_html would 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 "Global lang attribute" and "Global dir attribute" 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 as onclick are never a good fit here. filter_html removes 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_html is 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.

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: and tel: links. Allowed protocols are filtered on output by the filter_protocols container parameter; unusual schemes such as javascript: 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) and addTargetToExternalLinks; 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 implies noopener, but it is a good habit to confirm that in your support matrix, and filter_html_nofollow only adds nofollow.

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_library is loaded only on the format administration page.

  • drupal.class: an optional PHP class implementing CKEditor5PluginInterface for 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, in allowed_html syntax plus the special keywords <$text-container> and <$any-html5-element>. Use false if 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 are toolbarItem, filter, imageUploadStatus, requiresConfiguration and plugins.

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.

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'],
];

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_html only 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 custom meridian_curator format.

  • 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_html on 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_html to make something "just work". Allowing style or on* 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, the CKEDITOR.config object or the hook_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|class syntax, 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 the editor.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-uuid image breaks. Images embedded through CKEditor 5 need editor_file_reference enabled 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 to processed_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 #[Filter].

11.0

Further modules removed (Actions UI, Activity Tracker, Book, Forum, Statistics, Tour).

11.1

Object-oriented hooks with #[Hook], used for hook_ckeditor5_plugin_info_alter() above.

11.2

Older icon names in the drupalElementStyles key of plugin definitions deprecated, removed in 12.0.0.

11.4

check_markup(), filter_formats(), filter_default_format(), filter_fallback_format() deprecated (the first is removed from 13.0.0). _ckeditor5_theme_css() deprecated with no replacement. The Standard profile no longer creates the Article and Page types.

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.