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/workflowsand 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 |
|---|---|
|
The user who saved the revision (entity key |
|
When the revision was saved (entity key |
|
The revision log message (entity key |
|
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
reviewand "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 IDworkflow, config prefixworkflowand export keysid,label,typeandtype_settings, so a workflow namedmeridian_editorialis the fileworkflows.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 ( |
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:
-
It adds a computed, translatable base field
moderation_stateto every moderated entity type, with the form widgetmoderation_state_default(the "Change to" selector) and a pseudo-fieldcontent_moderation_controlthat a display can show. -
It enforces a new revision on every save of a moderated entity.
-
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). -
It generates one permission per transition.
-
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
-
Enable the modules:
ddev drush pm:install content_moderation(the dependency, Workflows, comes with it). -
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. -
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.
-
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. -
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.
| State | Published | Default revision | What visitors see |
|---|---|---|---|
Draft ( |
no |
no |
The previous published revision, if any. |
Review ( |
no |
no |
The previous published revision, if any. |
Published ( |
yes |
yes |
This revision. |
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.
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();
}
Source: API reference: Workflow class
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 |
|
Reviewer |
|
Curator |
|
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
Source: Drush: role:perm:add
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_contentto 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 asmeridian_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 ( |
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 ( |
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
Source: Scheduler project page
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 setmoderation_statecan 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":
-
move every page out of Review (or bulk update them with a script that sets
moderation_state); -
delete the transitions that use the state, then the state;
-
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 |
"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 |
"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 |
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 |
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
.modulefile, 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_stateand exposes a computedmoderation_statefield. -
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, whichsave()skips. -
The Moderated content page at
/admin/content/moderatedis the reviewers' dashboard; check its filters for custom workflows. -
Scheduled publishing is contributed: Scheduler (dates on the form) or Scheduled Transitions (transitions of revisions).