Content Moderation, Workflows and Revisions

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 time an editor saves a page, Drupal can keep the previous version instead of overwriting it. That ability, called revisions, is the foundation for undo, audit trails and, together with two core modules, for a full editorial process in which an author drafts, a reviewer approves and only then does a page go live. This page explains the three layers in order: how revisions work (the database tables, the revision log, the permissions and the Entity API), what the Workflows module provides (states, transitions and a configuration entity that holds them), and what Content Moderation adds on top (a computed moderation_state field, one permission per transition, pending revisions and the Moderated content dashboard). It then covers translations, scheduled publishing with contributed modules, security, the automation and testing options for developers, and the pitfalls that catch most teams.

Facts on this page were checked against the Drupal documentation, the API reference, the core source tree and the project pages of the contributed modules on 2026-10-30. The rest of this sub-section is written against Drupal 11.4.x, Drush 13/14 and PHP 8.3 or later, and so is this page. The examples use the fictitious Meridian Museum introduced in Introduction and architecture: a meridian_editorial workflow (draft, review, published, archived) for the exhibition and event content types, with the roles Content Editor, Curator and Reviewer described in Users, roles and permissions. Drupal 10 reaches its end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07; both are scheduled, not past, and Content Moderation and Workflows are not among the modules that Drupal 12 moves to contributed projects.

Who needs this and why

  • An editor sees a "Change to" selector on the node form instead of a Published checkbox, and a "Latest version" tab when a newer draft exists than the page visitors see.

  • A site builder designs the workflow (states, transitions, which content types it covers) at /admin/config/workflow/workflows and grants the transition permissions to roles.

  • An administrator decides who may bypass the process and keeps revision tables from growing without limit.

  • A developer reads and writes the moderation state through the Entity API, reacts to transitions in hooks, validates transitions in custom code and exports the workflow as configuration.

Revisions

What a revision is

A revisionable content entity keeps a row per saved version. Nodes, custom blocks, media items and taxonomy terms are revisionable in a standard Drupal 11 site; users and files are not. Two ideas matter more than any other on this page:

  • The default revision is the one that visitors see, that Views lists, and that is returned when code calls Node::load($nid). Exactly one revision of an entity is the default (per translation, see below).

  • A pending revision (also called a forward revision) is a saved revision that is newer than the default revision but not yet the default one. It exists in the database and can be previewed and edited, but visitors do not see it.

Without Content Moderation, Drupal makes every revision the default one when it is saved with "Create new revision" checked, and a pending revision can only be created by code. Content Moderation is the module that lets editors create pending revisions from the user interface.

Where revisions are stored

For nodes the data is split over four tables: node and node_field_data hold the default revision, and node_revision and node_field_revision hold every revision, including the default one. The vid column is the revision ID, nid the entity ID. The revision metadata is stored in node_revision:

Column Meaning

revision_uid

The user who saved the revision (entity key revision_user).

revision_timestamp

When the revision was saved (entity key revision_created).

revision_log

The revision log message (entity key revision_log_message).

revision_default

Whether the revision is the default one.

The mapping between the entity keys and the columns is the entity type’s revision_metadata_keys, which is why a custom entity type must declare it before the revision log appears; see Custom entity types. Every configurable field that is revisionable also has a node_revision__field_name table next to its node__field_name table, which is why heavy revisioning makes the database grow quickly (see Content types and fields).

The "Create new revision" setting and the revision log

On the content type’s edit form (/admin/structure/types/manage/exhibition), the Publishing options tab has a Create new revision checkbox that is checked by default. It is stored as new_revision: true in node.type.exhibition.yml. When it is checked, the node form shows a Revision log message text area where the editor explains what changed ("Corrected the opening hours for the dinosaur gallery"). Revision messages cost nothing and are the cheapest audit trail Drupal offers; reviewers read them on the Revisions tab at /node/{node}/revisions.

When a content type is placed under a Content Moderation workflow the checkbox is no longer shown and every save creates a new revision: the module enforces it, because a pending revision cannot exist without a new row.

Revisions tab, revert and delete

The Revisions tab lists every revision with its date, author and log message. For each revision the operations are Revert and Delete, and the current default revision is marked "Current revision". Reverting does not rewrite history: Drupal saves a copy of the chosen revision as a new revision, with a log message of the form "Copy of the revision from <date>". Nodes provide these permissions:

  • View all revisions, Revert all revisions and Delete all revisions (global permissions)

  • per content type permissions to revert and delete the revisions of one bundle, generated by \Drupal\node\NodePermissions::nodeTypePermissions

  • Bypass content access control, which includes everything above

The default revision cannot be deleted from the tab, and a revision can only be reverted by a user who also has access to edit the node. In the Meridian Museum the Reviewer role gets View all revisions on exhibitions but not Revert, because reviewers approve and request changes; reverting is a decision for the Curator.

Working with revisions in code

Revisions are a feature of the Entity API (see Entity API), not of nodes. The relevant interfaces are \Drupal\Core\Entity\RevisionableInterface, \Drupal\Core\Entity\RevisionLogInterface and, for storage, \Drupal\Core\Entity\RevisionableStorageInterface. The service below belongs to meridian_collection and shows the three operations code needs most: save a new revision with a log message, find the newest revision and compare it with the default one.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;

/**
 * Saves revisions of Meridian content with an audit-friendly log message.
 */
final class RevisionHelper {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly TimeInterface $time,
  ) {}

  /**
   * Saves a new revision of a node that is NOT under content moderation.
   */
  public function saveWithLog(NodeInterface $node, AccountInterface $account, string $message): void {
    $node->setNewRevision(TRUE);
    $node->setRevisionLogMessage($message);
    $node->setRevisionUserId((int) $account->id());
    $node->setRevisionCreationTime($this->time->getRequestTime());
    $node->save();
  }

  /**
   * Returns TRUE when the newest revision is not the one visitors see.
   */
  public function hasPendingRevision(NodeInterface $node): bool {
    $storage = $this->entityTypeManager->getStorage('node');
    $latest = $storage->getLatestRevisionId((int) $node->id());
    return $latest !== NULL && (int) $latest !== (int) $node->getRevisionId();
  }

  /**
   * Loads the newest revision, in the translation of the given node.
   */
  public function loadLatest(NodeInterface $node): NodeInterface {
    $storage = $this->entityTypeManager->getStorage('node');
    $revision_id = $storage->getLatestTranslationAffectedRevisionId(
      (int) $node->id(),
      $node->language()->getId(),
    );
    $latest = $revision_id ? $storage->loadRevision($revision_id) : $node;
    return $latest->hasTranslation($node->language()->getId())
      ? $latest->getTranslation($node->language()->getId())
      : $latest;
  }

}
# meridian_collection.services.yml
services:
  Drupal\meridian_collection\RevisionHelper:
    autowire: true

Three details of that code deserve attention. The service receives its collaborators through the constructor (autowired; see Services and dependency injection) instead of calling \Drupal::time(). getLatestRevisionId() returns the newest revision regardless of the translation, while getLatestTranslationAffectedRevisionId() returns the newest revision that changed a given language, which is the one Content Moderation uses. And the code never calls $node->isDefaultRevision(FALSE): for a moderated entity the default flag is decided by the moderation state (below), and setting it by hand fights the module.

What revisions do not do

  • Revisions store field values, not files. Uploading a new file to an image field creates a new file entity, and the old file stays on disk while an old revision references it. See File system and private files.

  • Core has no revision pruning. The contributed Node Revision Delete project deletes old revisions on a schedule; install it or write a queue worker (Queue, Batch and Cron APIs) for sites with frequent saves.

  • The core Revisions tab shows a list, not a visual comparison. The contributed Diff project adds a field-by-field comparison of two revisions, which reviewers appreciate.

  • The Revisions tab belongs to nodes, custom blocks and similar entity types that ship a revision UI. A custom revisionable entity type needs its own routes and forms for it.

The Workflows module

A generic state machine

The Workflows module (workflows, in core since 8.5 and a dependency of Content Moderation) provides a configuration entity type, workflow, which stores a set of states and transitions for something that has a life cycle. It does not know what the thing is: the behaviour belongs to a workflow type plugin. Core ships one workflow type, content_moderation, supplied by the Content Moderation module. The Workflows module is enabled automatically with it; its permission is Administer workflows.

  • A state is a machine name and a label, for example review and "In review". Content Moderation adds two flags to each state, described below.

  • A transition is a machine name, a label, a list of source states (from) and one target state (to). Content moves only along transitions that exist: if there is no transition from Draft to Published, nothing can go directly from one to the other.

  • A workflow is the configuration entity that holds states, transitions and the settings of its type. Its class is \Drupal\workflows\Entity\Workflow, with entity type ID workflow, config prefix workflow and export keys id, label, type and type_settings, so a workflow named meridian_editorial is the file workflows.workflow.meridian_editorial.yml.

The administration screens live under Configuration > Workflow > Workflows (/admin/config/workflow/workflows): adding a workflow, then Add state and Add transition forms, handled by the WorkflowStateAddForm and WorkflowTransitionAddForm classes. Machine names must match the VALID_ID_REGEX of the type plugin, which means lowercase letters, digits and underscores.

Content Moderation settings of a workflow

Because the type is content_moderation, the workflow form has three Content Moderation specific parts:

  • This workflow applies to: the entity types and bundles that the workflow controls. A bundle can be in only one Content Moderation workflow at a time. A workflow can govern nodes, custom blocks, media and taxonomy terms.

  • Default moderation state: the state a new entity starts in (draft for Meridian).

  • Initial moderation state when adding a new translation (only on sites with Content Translation): either the default moderation state, or the current state of the original language, which was the only behaviour before the change record Workflow setting to control the moderation state of new translations. See the translations section below.

Each state carries two flags that are the heart of Content Moderation:

Flag When checked Typical use

Published

Saving an entity in this state publishes it (status is 1)

The Published state.

Default revision

Saving an entity in this state makes the saved revision the default one

Published and Archived.

The four possible combinations give four kinds of states, and understanding them removes most of the mystery of moderation:

Published Default revision Result

yes

yes

The revision is live. This is the Published state.

no

yes

The revision becomes the default one but is unpublished. This is the Archived state: the page disappears from the site.

no

no

The revision is saved as a pending revision; visitors still see the previous default revision. This is Draft and Review.

yes

no

Not allowed. An unpublished state cannot be published, and a published revision must be the default one.

The last row is a combination the workflow form does not let you choose: a published state is always a default revision, because a published pending revision would be visible and invisible at the same time.

The Content Moderation module

What it adds

The Content Moderation module (content_moderation, in core since 8.4, requiring Workflows) does five things:

  1. It adds a computed, translatable base field moderation_state to every moderated entity type, with the form widget moderation_state_default (the "Change to" selector) and a pseudo-field content_moderation_control that a display can show.

  2. It enforces a new revision on every save of a moderated entity.

  3. It adds a Latest version tab (the link template latest-version, which is the canonical link followed by /latest) that appears when a pending revision exists, plus access checks for it (access_check.latest_revision).

  4. It generates one permission per transition.

  5. It provides the Moderated content view at /admin/content/moderated.

The module decides what to moderate by looking at entity types: only a revisionable, non-internal entity type can be moderated, and the path_alias and workspace entity types never are. Node, custom block and taxonomy term have dedicated moderation handlers, and any other type uses the generic ModerationHandler. The entry point for all of this in code is the content_moderation.moderation_information service.

The moderation state is stored in its own entity

The state is not a column of the node. Content Moderation keeps it in a separate content entity type, content_moderation_state, with the tables content_moderation_state and content_moderation_state_revision. Each row records the workflow, the moderated entity type and ID, the revision ID and the state. The class is marked @internal in core, and its documentation says that it must not be used to change the state of an entity: set the computed moderation_state field on the moderated entity instead. This matters when you export content or migrate it: the state of a node travels in a different entity from the node.

Enabling it step by step

  1. Enable the modules: ddev drush pm:install content_moderation (the dependency, Workflows, comes with it).

  2. Create or import a workflow (below). The module documentation describes a default Editorial workflow, created on sites installed with the Standard profile, with the states Draft, Published and Archived and the transitions Create New Draft, Publish, Archive, Restore to Draft and Restore. If the Editorial workflow is missing, create a workflow by hand or import its configuration file (workflows.workflow.editorial.yml). Drupal 11.4.0 removes the Article and Page content types from the Standard profile, so a new site must create a content type before it can attach a workflow; the Meridian content types are built in Content types and fields.

  3. Under This workflow applies to, select the entity types and bundles. Existing content is assigned a state when it is attached: published entities start as Published and unpublished entities start as Draft.

  4. Grant the Use <workflow> transition permissions to roles at /admin/people/permissions (the Content Moderation section), plus View the latest version and View any unpublished content to reviewers.

  5. Test with two users in two browsers. The most common mistake is to configure the workflow and forget the permissions, after which editors see a "Change to" list with a single entry or none.

States and transitions in practice: the Meridian workflow

The Meridian Museum separates writing from approval. Content Editors write and may submit their work for review. Reviewers (curators from other departments, external proofreaders) decide whether a page needs changes or may go live. Curators own the final publication and the archive. The workflow has four states and six transitions.

stateDiagram-v2 [*] --> Draft : new content (default state) Draft --> Review : submit_for_review (Content Editor) Review --> Draft : request_changes (Reviewer) Review --> Published : publish (Reviewer, Curator) Published --> Published : publish (Curator, keeps one live revision) Published --> Draft : create_new_draft (Content Editor) Draft --> Draft : create_new_draft (Content Editor) Published --> Archived : archive (Curator) Archived --> Draft : restore_to_draft (Curator) Archived --> [*]
State Published Default revision What visitors see

Draft (draft)

no

no

The previous published revision, if any.

Review (review)

no

no

The previous published revision, if any.

Published (published)

yes

yes

This revision.

Archived (archived)

no

yes

Nothing: the page is unpublished.

Because Draft and Review are not default revisions, an editor can revise a live exhibition page for weeks, send it for review and have it rejected without the public ever seeing a half-finished change. That is the property that makes moderation worth the setup.

The next diagram shows what happens when someone saves a moderated node; it is the same decision tree applied by Content Moderation for every entity.

flowchart TD S[Editor saves the node form] --> V{Is the transition valid<br/>for this user?} V -- no --> E[Validation error on the Change to selector] V -- yes --> N[New revision saved with<br/>the log message] N --> P{State is Published?} P -- yes --> D1[Revision is published and becomes the default revision] P -- no --> A{State is default revision?} A -- yes --> D2[Revision becomes the default revision but unpublished: Archived] A -- no --> D3[Pending revision: the previous default revision stays live]

The workflow as configuration

Everything the screens produce is one configuration file. This is workflows.workflow.meridian_editorial.yml, as exported to config/sync:

langcode: en
status: true
dependencies:
  config:
    - node.type.event
    - node.type.exhibition
  module:
    - content_moderation
id: meridian_editorial
label: 'Meridian editorial'
type: content_moderation
type_settings:
  states:
    archived:
      label: Archived
      weight: 3
      published: false
      default_revision: true
    draft:
      label: Draft
      weight: -2
      published: false
      default_revision: false
    published:
      label: Published
      weight: 2
      published: true
      default_revision: true
    review:
      label: 'In review'
      weight: -1
      published: false
      default_revision: false
  transitions:
    archive:
      label: Archive
      from:
        - published
      to: archived
      weight: 4
    create_new_draft:
      label: 'Create new draft'
      from:
        - draft
        - published
      to: draft
      weight: 0
    publish:
      label: Publish
      from:
        - published
        - review
      to: published
      weight: 3
    request_changes:
      label: 'Request changes'
      from:
        - review
      to: draft
      weight: 2
    restore_to_draft:
      label: 'Restore to draft'
      from:
        - archived
      to: draft
      weight: 5
    submit_for_review:
      label: 'Submit for review'
      from:
        - draft
      to: review
      weight: 1
  entity_types:
    node:
      - event
      - exhibition
  default_moderation_state: draft

The keys follow the schema workflow.type_settings.content_moderation: states (each with published and default_revision), transitions, entity_types (a map from entity type ID to a list of bundle IDs) and default_moderation_state. The dependencies list is what ties the workflow to the content types: delete the exhibition content type and the workflow loses its bundle. The weights decide the order of the states in the "Change to" list, so keep them ascending in the order an editor would want: draft, review, published, archived.

A workflow can also be created in code, for example in a post-update function of meridian_collection that adds the workflow to sites that predate it (see Update API and post-updates):

<?php

/**
 * @file
 * Post-update functions for Meridian collection.
 */

use Drupal\workflows\Entity\Workflow;

/**
 * Adds the Meridian editorial workflow when it does not exist.
 */
function meridian_collection_post_update_add_editorial_workflow(): void {
  if (Workflow::load('meridian_editorial')) {
    return;
  }
  $workflow = Workflow::create([
    'id' => 'meridian_editorial',
    'label' => 'Meridian editorial',
    'type' => 'content_moderation',
    'type_settings' => [
      'states' => [
        'draft' => ['label' => 'Draft', 'weight' => -2, 'published' => FALSE, 'default_revision' => FALSE],
        'review' => ['label' => 'In review', 'weight' => -1, 'published' => FALSE, 'default_revision' => FALSE],
        'published' => ['label' => 'Published', 'weight' => 2, 'published' => TRUE, 'default_revision' => TRUE],
        'archived' => ['label' => 'Archived', 'weight' => 3, 'published' => FALSE, 'default_revision' => TRUE],
      ],
      'transitions' => [
        'submit_for_review' => ['label' => 'Submit for review', 'from' => ['draft'], 'to' => 'review', 'weight' => 1],
        'request_changes' => ['label' => 'Request changes', 'from' => ['review'], 'to' => 'draft', 'weight' => 2],
        'publish' => ['label' => 'Publish', 'from' => ['review', 'published'], 'to' => 'published', 'weight' => 3],
      ],
      'entity_types' => [],
      'default_moderation_state' => 'draft',
    ],
  ]);
  $workflow->getTypePlugin()
    ->addEntityTypeAndBundle('node', 'exhibition')
    ->addEntityTypeAndBundle('node', 'event');
  $workflow->save();
}

The type plugin ($workflow->getTypePlugin()) is where the API for a workflow lives. WorkflowTypeBase provides addState(), addTransition(), setStateLabel(), deleteState(), getState(), getStates(), getTransition(), getTransitionsForState() and getTransitionFromStateToState(); the ContentModeration plugin (plugin ID content_moderation) adds addEntityTypeAndBundle(), removeEntityTypeAndBundle(), appliesToEntityTypeAndBundle(), getEntityTypes(), getBundlesForEntityType(), workflowHasData() and workflowStateHasData(). For deployments the configuration file is the better route, because it travels with configuration management and a recipe can ship it; the PHP route is for sites that must be upgraded in place.

The Meridian recipe meridian_events ships the same workflow as config/workflows.workflow.meridian_editorial.yml; a recipe adds the file once and, because recipes have no update path, later changes are made by the site itself.

Permissions

One permission per transition

Content Moderation generates a permission for every transition of every workflow, through the permission callback transitionPermissions() in content_moderation.permissions.yml. The machine name is

use {workflow_id} transition {transition_id}

so the Meridian workflow produces use meridian_editorial transition submit_for_review, use meridian_editorial transition publish and so on. On the permissions page the title is "Meridian editorial workflow: Use Publish transition", with a description of the form "Move content from In review, Published states to Published state". Each permission depends on the workflow configuration, so deleting the workflow deletes the permission from every role.

Besides the generated permissions, two fixed permissions in the same file matter for reviewers:

  • View any unpublished content lets a user open unpublished nodes (pending revisions are unpublished).

  • View the latest version lets a user open the Latest version tab. It requires View any unpublished content or View own unpublished content.

The Meridian assignment is a clear example of the least-privilege principle:

Role Permissions

Content Editor

use meridian_editorial transition create_new_draft, use meridian_editorial transition submit_for_review, View own unpublished content, View the latest version

Reviewer

use meridian_editorial transition request_changes, use meridian_editorial transition publish, View any unpublished content, View the latest version, View all revisions

Curator

use meridian_editorial transition create_new_draft, use meridian_editorial transition publish, use meridian_editorial transition archive, use meridian_editorial transition restore_to_draft, View any unpublished content, Revert all revisions

Administrator

All of the above through the administrator role flag

# Assign the permissions from the table above to the Reviewer role (names are machine names).
ddev drush role:perm:add reviewer "use meridian_editorial transition request_changes"
ddev drush role:perm:add reviewer "use meridian_editorial transition publish"
ddev drush role:perm:add reviewer "view any unpublished content"
ddev drush role:perm:add reviewer "view latest version"
ddev drush config:export -y

Exporting afterwards puts the grants into user.role.reviewer.yml, where the review in the pull request will show exactly which transitions the role can use.

How the check is made

When the editor submits the node form, the ModerationState constraint compares the original state with the chosen one. It asks content_moderation.state_transition_validation (class StateTransitionValidation), whose getValidTransitions($entity, $account) returns the transitions out of the current state that the account may use, and whose isTransitionValid() checks one transition. Both end in a plain $account->hasPermission() call with the permission string above. Two consequences:

  • A user with Bypass content access control is not automatically allowed to use every transition. The role must have the transition permission (a role with the administrator flag has every permission, including these).

  • The check is a constraint, so it runs when the entity is validated. The node form, JSON:API and REST validate before saving, so the rules hold there. A plain $node->save() in custom code does not validate, so a migration, a Drush command or a queue worker can move content along any state, including along a path that has no transition. Call $node->validate() and check the returned violations when the code acts on behalf of a user.

Reading transitions in custom code

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Controller;

use Drupal\content_moderation\StateTransitionValidationInterface;
use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;

/**
 * Lists the transitions the current user may apply to a node.
 */
final class TransitionsController extends ControllerBase {

  public function __construct(
    private readonly StateTransitionValidationInterface $validation,
    private readonly ModerationInformationInterface $moderationInformation,
    private readonly AccountInterface $account,
  ) {}

  public static function create(ContainerInterface $container): self {
    return new self(
      $container->get(StateTransitionValidationInterface::class),
      $container->get(ModerationInformationInterface::class),
      $container->get('current_user'),
    );
  }

  public function list(NodeInterface $node): JsonResponse {
    if (!$this->moderationInformation->isModeratedEntity($node)) {
      return new JsonResponse(['moderated' => FALSE]);
    }
    $workflow = $this->moderationInformation->getWorkflowForEntity($node);
    $transitions = $this->validation->getValidTransitions($node, $this->account);
    return new JsonResponse([
      'moderated' => TRUE,
      'workflow' => $workflow->id(),
      'state' => $node->get('moderation_state')->value,
      'transitions' => array_map(
        static fn ($transition) => ['id' => $transition->id(), 'to' => $transition->to()->id()],
        array_values($transitions),
      ),
    ]);
  }

}
# meridian_collection.routing.yml
meridian_collection.node_transitions:
  path: '/meridian/node/{node}/transitions'
  defaults:
    _controller: '\Drupal\meridian_collection\Controller\TransitionsController::list'
    _title: 'Available transitions'
  requirements:
    _entity_access: 'node.update'
    node: \d+

The interface aliases (StateTransitionValidationInterface, ModerationInformationInterface) are registered as services by Content Moderation next to the string IDs content_moderation.state_transition_validation and content_moderation.moderation_information, so either form works with autowiring. The ModerationInformation service also answers the other common questions: isModeratedEntity(), isModeratedEntityType(), hasPendingRevision(), isLiveRevision(), isDefaultRevisionPublished(), getDefaultRevisionId(), getOriginalState() and getWorkflowForEntityTypeAndBundle().

The editor’s experience

The node form and the Latest version tab

On the node edit form of a moderated bundle:

  • the Published checkbox is replaced by the moderation control, a select labelled Change to placed in the form footer, showing the current state next to it. It lists only the target states of transitions the user may use;

  • the Revision log message area is shown, and the new revision is always created;

  • after saving into a non-default state (Draft or Review), the editor lands on the Latest version tab (/node/{node}/latest) instead of the canonical page, because the canonical page still shows the previous default revision.

A user who lacks View the latest version sees the old content only. That is the usual cause of a complaint of the form "I saved it and nothing changed": the change exists, but is a pending revision.

The Moderated content dashboard

The Moderated content page (/admin/content/moderated, Content > Moderated content) is the dashboard for reviewers. It is the view moderated_content, shipped as optional configuration of the Content Moderation module (if you import a configuration set without it, the page does not exist), and it requires View any unpublished content. It lists the items whose latest revision is a pending one, with columns Title, Content type, Author, Moderation state, Updated and Operations, and exposed filters for title, content type, moderation state and language. Two properties are worth knowing:

  • The view is a normal view, so you can edit it at /admin/structure/views/view/moderated_content to add the workflow filter, a Revision log message column or a role-based default filter ("show me only Review"). It is written against the state IDs of the Editorial workflow. For a custom workflow such as meridian_editorial, open the view and check the filters, otherwise the dashboard may not list the states you expect. See Views.

  • If a bundle is not yet attached to any workflow, the view can report a "Broken/missing handler" until at least one content type is attached.

Contributed modules improve the editor’s experience. Moderation Sidebar puts the transition buttons in an off-canvas tray on the front end, Content Moderation Notifications e-mails the role that is next in the workflow when a state changes, and Diff shows what a revision changed. They are not part of core, so evaluate their support for your Drupal version before depending on them.

Reacting to state changes in code

Since Drupal 11.1 hooks are written as methods with the #[Hook] attribute on autowired classes (see Hooks and OOP hooks). The class below logs, and could just as well e-mail, every time an exhibition enters the review state.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\content_moderation\ModerationInformationInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Notifies reviewers when an exhibition enters the review state.
 */
final class ModerationHooks {

  public function __construct(
    private readonly ModerationInformationInterface $moderationInformation,
    #[Autowire(service: 'logger.channel.meridian_collection')]
    private readonly LoggerInterface $logger,
  ) {}

  #[Hook('node_update')]
  public function nodeUpdate(NodeInterface $node): void {
    if ($node->bundle() !== 'exhibition' || !$this->moderationInformation->isModeratedEntity($node)) {
      return;
    }
    $new = $node->get('moderation_state')->value;
    $old = $this->moderationInformation->getOriginalState($node)->id();
    if ($new === 'review' && $old !== 'review') {
      $this->logger->info('Exhibition @title is waiting for review (revision @vid).', [
        '@title' => $node->label(),
        '@vid' => $node->getRevisionId(),
      ]);
    }
  }

}
# meridian_collection.services.yml
services:
  logger.channel.meridian_collection:
    parent: logger.channel_base
    arguments: ['meridian_collection']
  Drupal\meridian_collection\Hook\ModerationHooks:
    autowire: true

Reading the state is simple ($node->get('moderation_state')->value). Writing it is the supported way to move content:

$node = \Drupal\node\Entity\Node::load(42);
$node->set('moderation_state', 'review');
$node->setRevisionLogMessage('Ready for review: gallery text proofread.');
$violations = $node->validate();
if ($violations->count() === 0) {
  $node->save();
}

The static Node::load() call is acceptable in a snippet, a Drush script or a test, and not in a service, where the entity type manager is injected. The validate() call is what makes the permission and transition rules apply; use it whenever a human triggered the change.

Translations

Moderation is per translation: each language of a node has its own moderation_state value and its own pending revisions, and Content Moderation tracks the translation affected revision so that publishing the Spanish translation does not publish a draft of the English one. In practice:

  • a Spanish translation can be Published while the English original has a pending Draft;

  • saving a translation creates a revision that records which language it changed (getLatestTranslationAffectedRevisionId() finds it);

  • the workflow setting Initial moderation state when adding a new translation decides whether a new translation starts in the default state (Draft for Meridian) or copies the state of the original. The default state is the safe choice, because copying "Published" would publish a machine-translated text that nobody has read.

The permissions are not per language. Translation workflows beyond that (who translates, how the interface strings are handled) are in Multilingual.

Scheduled publishing

Core Content Moderation changes state when someone clicks a button; it cannot say "publish this on Friday at nine". Two contributed modules fill the gap and they solve different problems.

Module What it does When to choose it

Scheduler (drupal/scheduler)

Adds Publish on and Unpublish on date fields to the entity form. Nodes, media, taxonomy terms and products are supported. Its sub-module Scheduler Content Moderation Integration lets the date change the moderation state instead of the plain published flag.

Editors schedule a page while writing it; per content type opt-in.

Scheduled Transitions (drupal/scheduled_transitions)

Lets a user schedule a specific revision to move to a given moderation state on a given date, with a "Scheduled transitions" tab. It works only with Content Moderation workflows, supports many entity types (nodes, custom blocks) and individual translations, and does not add fields to the entity.

Editors who think in transitions, not in dates on the form; several scheduled changes of one page.

Versions on the check date: Scheduler 2.3.0 (2026-06-05) declares compatibility with Drupal ^9 || ^10 || ^11; its project page does not list Drupal 12. Scheduled Transitions 2.8.4 is the latest stable release for ^11.1 with PHP 8.3 or later; the 2.9.0-rc1 release candidate of 2026-10-29 requires ^11.3 || ^12 and PHP 8.5 or later, and the 2.7 branch still supports ^10.3 || ^11. Scheduled Transitions depends on Content Moderation, Workflows and Dynamic Entity Reference. Because Drupal 10 ends on 2026-12-09, choose the 2.8 line for Drupal 11 and watch 2.9 for Drupal 12.

# Scheduler with the content moderation integration (versions as on 2026-10-30)
ddev composer require 'drupal/scheduler:^2.3'
ddev drush pm:install scheduler scheduler_content_moderation_integration

# The alternative that schedules transitions of specific revisions
ddev composer require 'drupal/scheduled_transitions:^2.8'
ddev drush pm:install scheduled_transitions

# Both modules do their work on cron
ddev drush core:cron

For Meridian, Scheduler is enabled on the event type, so an editor can publish "Late night at the museum" the evening before, while exhibitions keep manual publication by a Curator because a scheduled public opening needs a person’s decision. With the integration module enabled, the scheduled publish and unpublish dates are accompanied by the target moderation states, which must be valid transitions from the state in which the revision sits when the time comes.

Scheduled changes run on cron. If cron runs once a day, a page scheduled for 09:00 goes live at the next cron run. Set up a reliable cron (for example drush core:cron from the host every minute or five) as described in Cron, queues and maintenance; a lightweight Scheduler cron job is also available. Time zones are the second trap: dates are entered in the editor’s time zone and compared to the server clock in UTC, so test the scheduled time with an editor whose time zone differs from the site default.

Workspaces and moderation

Core Workspaces (see Workspaces) is a different mechanism: instead of moving each page through states, it groups many changes in an isolated space and publishes them together, for example a whole spring exhibition season. Content Moderation and Workspaces can be enabled on the same site, and Content Moderation includes a subscriber that handles workspace-aware behaviour. Use Content Moderation for the daily life of single pages, and Workspaces for coordinated launches of many pages; do not use workspaces to avoid designing a workflow.

Security considerations

  • Least privilege per transition. Give the Content Editor role no Publish transition, and test it by logging in as that role. The most frequent security failure is granting Bypass content access control to "make things work", which opens every node whatever the state.

  • Draft content is private only if you keep it private. Pending revisions are unpublished, but View any unpublished content shows every unpublished node. Grant it to Reviewers and Curators, not to Members. Search and JSON:API respect node access, so an unpublished draft does not appear for anonymous users; the first thing to do before exposing a decoupled site (JSON:API) is to read the API as an anonymous user and as a Member.

  • Do not grant Administer workflows lightly. A user with Administer workflows can add a transition from Draft straight to Published, then give themselves the permission for it if they also administer permissions. Treat this permission like Administer permissions.

  • Revision logs are visible to everyone who can see revisions. Do not put personal data in log messages ("approved after phone call with the donor"), because the log persists in the database, in backups and in exports.

  • Code that bypasses validation. As noted above, $node->save() does not validate. Imports and update hooks that set moderation_state can move content around the permission model, which is acceptable in a trusted script and an incident in code that acts for a user.

  • Archived is not deleted. The content, its revisions and its files remain in the database and in the private file system. If content must be removed for legal reasons, delete the entity and its revisions, not only archive it.

  • The ordinary protections still apply: Twig autoescaping on revision messages, and CSRF tokens on revert and delete links. See Security hardening.

Configuration management and deployment

The workflow, the permissions of the roles and the content type settings are configuration; the moderation state of each page is content. A deployment therefore ships workflows.workflow.meridian_editorial.yml, the role files and, if used, the Scheduler settings, with drush deploy (see Drush). Content Moderation checks configuration imports (through its content_moderation.config_import_subscriber service), and the workflow type plugin exposes workflowStateHasData() and workflowHasData() so that a state or workflow that still holds content is not removed by accident. In practice, to retire the state "In review":

  1. move every page out of Review (or bulk update them with a script that sets moderation_state);

  2. delete the transitions that use the state, then the state;

  3. export the configuration and review the diff before importing it on other environments.

Never delete a workflow on production to "start again": the moderation state entities of every page go with it.

Common errors and how to fix them

Symptom Cause and fix

The "Change to" selector is empty

The role has no use <workflow> transition <id> permission for a transition leaving the current state, or the state has no outgoing transition. Add the permission or the transition.

"I published it and visitors still see the old page"

The saved state was not a default revision (Review), the page cache was not cleared, or the editor is looking at the Latest version tab. Check the flags of the state and the Revisions tab.

Published content disappears after sending it for review

The Review state has Default revision checked, or the transition into Review starts from Published. Make Review a non-default, unpublished state and route live pages through create_new_draft.

"Broken/missing handler" in the Moderated content view

No bundle is attached to a workflow yet, or the view references a deleted field. Attach a bundle and re-save the view.

The Editorial workflow is missing

It is optional configuration that the module documentation says is created on Standard profile sites. Import workflows.workflow.editorial.yml or create the workflow by hand.

A content type cannot be selected in a workflow

It is already in another Content Moderation workflow. Remove it there first.

A state cannot be deleted

Pages still have that state. Move them to another state first.

The Revisions tab is missing

The user lacks View all revisions or the entity type is not revisionable.

Scheduled content does not change at the expected time

Cron is not running often enough or the editor’s time zone differs from the site’s. Run cron every minute or five and test with a known time zone.

Hundreds of thousands of revisions slow the database

There is no pruning in core. Use Node Revision Delete or a queue worker, and keep log messages short.

Custom code moves content but nothing is published

The code set status directly. For a moderated entity set moderation_state; the flags of the state decide status.

Testing a workflow

A kernel test proves that the flags of each state do what the documentation says and costs a few seconds. The test below builds the same workflow from an array and checks that a node in Review is not published while a node in Published is. See Automated testing for the setup of the test environment.

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\workflows\Entity\Workflow;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the state flags of the Meridian editorial workflow.
 */
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class EditorialWorkflowTest extends KernelTestBase {

  protected static $modules = [
    'system', 'user', 'node', 'field', 'text', 'filter', 'workflows', 'content_moderation',
  ];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('node');
    $this->installEntitySchema('content_moderation_state');
    $this->installSchema('node', ['node_access']);
    NodeType::create(['type' => 'exhibition', 'name' => 'Exhibition'])->save();
    $workflow = Workflow::create([
      'id' => 'meridian_editorial',
      'label' => 'Meridian editorial',
      'type' => 'content_moderation',
      'type_settings' => [
        'states' => [
          'draft' => ['label' => 'Draft', 'weight' => 0, 'published' => FALSE, 'default_revision' => FALSE],
          'review' => ['label' => 'In review', 'weight' => 1, 'published' => FALSE, 'default_revision' => FALSE],
          'published' => ['label' => 'Published', 'weight' => 2, 'published' => TRUE, 'default_revision' => TRUE],
        ],
        'transitions' => [
          'submit_for_review' => ['label' => 'Submit for review', 'from' => ['draft'], 'to' => 'review', 'weight' => 0],
          'publish' => ['label' => 'Publish', 'from' => ['review'], 'to' => 'published', 'weight' => 1],
        ],
        'entity_types' => [],
        'default_moderation_state' => 'draft',
      ],
    ]);
    $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'exhibition');
    $workflow->save();
  }

  public function testStateFlags(): void {
    $node = Node::create(['type' => 'exhibition', 'title' => 'Deep time', 'moderation_state' => 'review']);
    $node->save();
    $this->assertFalse($node->isPublished());

    $node->set('moderation_state', 'published');
    $node->save();
    $reloaded = Node::load($node->id());
    $this->assertTrue($reloaded->isPublished());
    $this->assertSame('published', $reloaded->get('moderation_state')->value);
  }

}

The #[Group] and #[RunTestsInSeparateProcesses] attributes replace the @group annotations of older tests: PHPUnit 11, used from Drupal 11.2, prefers attributes, and PHPUnit 10 support was dropped in 11.3. A functional test with BrowserTestBase and drupalLogin() is the right tool for checking the permission matrix of the roles.

Version notes and corrections of older material

  • Module history. Workflows has been in core since 8.5 and Content Moderation since 8.4, so every tutorial since then describes the same state and transition model.

  • Standard profile. Older books and tutorials, among them Drupal 10 Development Cookbook (Packt, 2023, written for Drupal 10.0) and Drupal 10 Masterclass (Packt, 2023, Drupal 10.0 and 10.1), attach the Editorial workflow to the Article or Basic page types. Drupal 11.4.0 no longer creates those content types in the Standard profile, so create a content type first (as the Meridian example does) and then attach the workflow.

  • Code samples. The same books show hooks as functions in a .module file, plugin annotations and static \Drupal:: calls. Since 11.1 hooks are #[Hook] methods on autowired classes, since 10.2 plugins use PHP attributes and annotations are on a deprecation path, and classes receive their services in the constructor, as the examples above do. The transition API itself (states, transitions, moderation_state) is unchanged since Drupal 9.

  • Drupal 10. Drupal 10 ends on 2026-12-09 and 10.6 is its last minor release. New sites start on 11.4 or later, and the Scheduler and Scheduled Transitions releases above are chosen for that.

  • Drupal 12. Content Moderation and Workflows remain in core in the planned 12.0.0. What does change are the modules around them: Claro, Olivero and Stable 9 leave core (Default Admin, based on Gin, becomes the admin theme), the Toolbar moves to a contributed project, and query objects are no longer serialisable, so test the Moderated content page, the Latest version tab and the scheduling modules in a copy of the site before the upgrade. See Preparing for Drupal 12.

  • Canvas and Layout Builder. Layout Builder stays in core and works with pending revisions of the node it edits; Canvas (contributed) targets Drupal 11.3 and later and has its own page; see Canvas visual page building and Layout Builder. Check the current Canvas documentation for how it interacts with moderated content before combining the two.

Older advice also collected in Where older books and tutorials are outdated.

Summary

  • Revisions keep history; the default revision is the live one; a pending revision is saved but not live.

  • Workflows defines states and transitions as configuration; Content Moderation applies them to entities, stores the state in content_moderation_state and exposes a computed moderation_state field.

  • The two state flags Published and Default revision decide what a save does. Draft and Review are unpublished and not default; Published is both; Archived is default and unpublished.

  • Every transition is a permission, use <workflow> transition <id>, enforced by validation, which save() skips.

  • The Moderated content page at /admin/content/moderated is the reviewers' dashboard; check its filters for custom workflows.

  • Scheduled publishing is contributed: Scheduler (dates on the form) or Scheduled Transitions (transitions of revisions).