Content moderation workflow
Goal
Provision an editorial workflow with core content_moderation + workflows — a state machine (workflows.workflow.{id}) with the right states and transitions, moderation enabled on the target bundle(s) via the add_moderation config action, and existing content backfilled — then emit the transition IDs as a contract for the roles recipe to grant. The workflow owns states/transitions/bundles; it does not grant permissions.
Opinion
Do not hardcode one canonical state set. Core's editorial (draft/published/archived, five transitions) and Drupal CMS's basic_editorial (draft/published/unpublished, three transitions) are both real, shipped shapes. Default to the leaner three-state variant and add archived only when the site needs a distinct terminal archive. Never assume archived exists. Source: guide drupal/editorial-workflow/content-moderation-state-machine.
Enable moderation on bundles via the add_moderation config action, not raw entity_types. The action (derived per entity type as addNodeTypes, addTaxonomyVocabularies, …) wires the moderation handlers; hand-editing type_settings.entity_types bypasses that wiring. Source: guide drupal/editorial-workflow/content-moderation-state-machine.
published and default_revision are distinct flags. published controls public visibility; default_revision controls which revision is canonical. An unpublished archived state still sets default_revision: true. Get both right per state. Source: guide drupal/editorial-workflow/content-moderation-state-machine.
Enabling moderation on existing content requires a backfill. moderation_state is a computed field backed by separate content_moderation_state entities; there is no automatic backfill. Existing revisions read as un-moderated until resaved — batch-resave them and map publish status to the correct state, or the workflow is not truly live. Source: guide drupal/editorial-workflow/content-moderation-existing-content-migration.
Permissions are emitted, not granted here. The workflow produces use {workflow} transition {id} IDs; the editorial_roles_permissions recipe grants them. This keeps workflows.workflow.* (this recipe) cleanly separated from user.role.*. Source: guide drupal/editorial-workflow/content-moderation-state-machine.
What this recipe refuses
- Assuming an
archivedstate exists when writing transitions or verifiers. - Adding bundles by hand-editing
type_settings.entity_typesinstead of theadd_moderationaction. - Declaring the workflow live on a bundle with pre-existing content without backfilling that content.
- Granting
use {workflow} transition {id}permissions to roles (that is the roles recipe's job). - Blindly resaving existing content with the default state, risking unpublishing live pages.
Preconditions
- Drupal 10.4+ or 11.x; the core
content_moderationandworkflowsmodules. - The target bundle(s) exist.
- Config export is in use, so the workflow is deployable.
- For a bundle with existing content, a backfill mechanism (Batch API / queue) is available.
- DDEV runs the site; the verifier's commands go through
ddev.
Input contract
Generic, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
workflow:
id: string # e.g. editorial
label: string
states: # default to a 3-state model unless specified
- id: string # draft | published | unpublished | archived …
label: string
weight: integer
published: boolean
default_revision: boolean
transitions:
- id: string # e.g. create_new_draft, publish
label: string
from: [string]
to: string
default_moderation_state: string
enable_on: # bundles to moderate
entity_type: string # node | taxonomy_term …
bundles: # bundle ids, or '*' for all
- string
backfill_existing: boolean # resave existing content to seed state (default true)
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview instead of writing config. Dry-run is required.
-
Validate preconditions —
content_moderation+workflowsenabled; target bundle(s) exist. Seedrupal/editorial-workflow/content-moderation-state-machine. -
Resolve the state model — from input, or default to a three-state (draft/published/archived or draft/published/unpublished) shape; set
published/default_revisionper state anddefault_moderation_state. Do not assumearchived. Seedrupal/editorial-workflow/content-moderation-state-machine. -
Author the workflow config-first —
workflows.workflow.{id}withtype: content_moderation, the states, transitions, anddefault_moderation_state; leaveentity_typesempty at this step. -
Enable moderation on each target bundle via the
add_moderationaction (addNodeTypes/addTaxonomyVocabularies, or*) — never by hand-editingentity_types. Seedrupal/editorial-workflow/content-moderation-state-machine,drupal/recipes/config-actions-entity-specific. -
Detect pre-existing content on the bundle(s). If present and
backfill_existing, batch-resave each entity, mapping publish status to the correct moderation state; if backfill is deferred, raise an operator-visible flag. Seedrupal/editorial-workflow/content-moderation-existing-content-migration. -
Emit the transition-permission contract — the list of
use {workflow_id} transition {id}strings — as input foreditorial_roles_permissions. Do not grant them here. -
Export configuration, rebuild caches, and emit a summary — states/transitions created, bundles enabled, backfill performed or flagged, and the transition IDs handed off.
Data flow
input: workflow (id, label, states[], transitions[], default_moderation_state)
enable_on (entity_type, bundles[])
backfill_existing
reads project state:
workflows.workflow.* (existing workflow for the id/bundles)
node type / bundle list
existing content on the target bundles (to decide backfill)
applies opinion (guardrails):
no-hardcoded-states · add_moderation-action-not-raw-entity_types ·
published≠default_revision · backfill-existing-content ·
emit-transitions-do-not-grant
references atomic detail (guides):
drupal/editorial-workflow/content-moderation-state-machine
drupal/editorial-workflow/content-moderation-existing-content-migration
drupal/recipes/config-actions-entity-specific
emits:
workflows.workflow.<id> (states, transitions, default_moderation_state)
type_settings.entity_types (via add_moderation action, not raw edit)
content_moderation_state entities (backfill for existing content)
transition IDs: use <workflow> transition <id> → editorial_roles_permissions
State-awareness contract
The recipe reads existing state before writing. For the workflow config: absent → create; present and matching the resolved spec → skip, log no-op; present and differing → conflict, do not overwrite, request operator review. Adding a bundle to an existing workflow via add_moderation is a create; removing states/transitions from a workflow already in use is a conflict (it can strand content in a deleted state) and is never applied silently.
Enabling moderation on a bundle with existing content is not complete until that content is backfilled — the recipe treats an un-backfilled bundle as a raised flag, not a finished state. Backfill maps each entity's publish status to a moderation state deliberately, so a resave never flips live content's visibility.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run (no duplicate states, no re-backfill of already-stated content).
Verifier
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only; a non-interactive ddev drush keeps Drush's messages on standard error. A stopped DDEV project prints its start-up text on standard output, so start the site before the verify run. Entries that call .aida/content-moderation-workflow/verify.php run the script in ## Files. It prints one line per violation and exits non-zero when it printed any.
verifier: - id: workflow-shape kind: config-assert run: ddev drush php:script /var/www/html/.aida/content-moderation-workflow/verify.php -- workflow {workflow:json} pass: stdout empty - id: moderation-on-bundles kind: config-assert run: ddev drush php:script /var/www/html/.aida/content-moderation-workflow/verify.php -- bundles {workflow.id} {enable_on.entity_type} {enable_on.bundles} pass: stdout empty - id: backfill-complete kind: config-assert run: ddev drush php:script /var/www/html/.aida/content-moderation-workflow/verify.php -- backfill {workflow.id} {enable_on.entity_type} {enable_on.bundles} {backfill_existing:json} pass: stdout empty - id: active-equals-export kind: config-assert run: ddev drush config:status pass: stdout empty
What the entries do not prove, and where the proof is:
workflow-shapealways checks the type, that every published state is also the default revision, and thatdefault_moderation_statenames a state. It compares states, transitions and the default state with the input only where the input gives them. When the input leavesstatesout, the summary carries the resolved model.moderation-on-bundlesandbackfill-completerun once per bundle inenable_on.bundles, and*checks every bundle of the entity type. An empty list reads unknown, not pass.moderation-on-bundlesproves the bundle is moderated by this workflow. It cannot tell theadd_moderationaction from a rawentity_typesedit, because both leave the same config.backfill-completereads content, not config, and names up to 20 entities with no moderation state. An absentbackfill_existingcounts as true, its default. When it is false, the entry checks nothing, and the summary carries the operator flag.- The transition-permission list handed to the roles recipe is not checked here. It is in the summary, and the roles recipe's
transition-permissionsentry checks the grants. active-equals-exportproves the export is current after Sequence step 7. The state-awareness contract makes a second apply a no-op on that state.
Files
One script, which the consumer writes before the verifier runs and removes after it. Do not edit it or commit it. Drush runs it with Drupal booted and passes the words after -- in $extra.
```php .aida/content-moderation-workflow/verify.php
| bundles