On this page
- 📖 Entire Menu Revisions guide
- Overview
- How a menu revision is stored
- Requirements
- Menu Revision Settings
- Selecting menus
- Menus selected for revisioning
- Menus not selected for revisioning
- Changing the menu selection
- Database tables
- menu_revision
- menu_revision_link
- menu_revision_hierarchy
- Inspecting a revision by hand
- Services
- menu_revisions.manager
- The menu tree manipulator
- Hook implementations
- Routes and tabs
- Permissions
- Revision states
- Draft
- Published
- Saving a draft
- Editing a draft
- Creating a new menu item
- Creating a child menu item
- Disabling an existing menu item in a draft
- Disabling an existing menu item and publishing
- Deleting a menu item
- Deleting a menu item that has children
- Updating menu item details
- Reordering menu items
- Previewing a draft
- Reverting to an older revision
- Revision history
- Expected behavior summary
- Revision behavior at a glance
- Related modules
- Related modules
- Installation
- Uninstallation
- Support and contributions
Entire Menu Revisions
Entire Menu Revisions provides revision management for complete Drupal menus. It allows editors and administrators to save menu changes as drafts, preview them, publish new revisions, review revision history, and revert a menu to an earlier state.
Project name vs. module name. The project is entire_menu_revisions, but the module it ships is menu_revisions. Every machine name below — the module, its services, its config object, its routes, its database tables — uses the menu_revisions prefix.
Entire Menu Revisions works alongside Menu Link Revisions. Menu Link Revisions provides revisions for individual menu_link_content entities; Entire Menu Revisions is a hard dependency on it and uses those link revisions as the building blocks of a whole-menu revision — menu items, hierarchy, ordering, and enabled state.
📖 Entire Menu Revisions guide
This documentation describes how to configure menus for revisioning, what the module stores in the database, which hooks and services it provides, and how menu revisions behave when creating drafts, publishing, previewing, adding and removing menu items, changing hierarchy and ordering, and reverting previous revisions.
See also:
Entire Menu Revisions project page
Overview
Drupal menus normally represent the current state of a site's navigation. Entire Menu Revisions introduces revision management so editors can work on menu changes without immediately changing the currently published menu.
A menu revision represents the state of the menu at a particular point in time. Editors can work with a draft, preview its changes, and publish it when the changes are ready.
Draft changes do not automatically change the live menu. The currently published menu remains unchanged until a revision is published.
How a menu revision is stored
A menu revision is not a copy of the menu. It is a snapshot of pointers:
- A
menu_revisioncontent entity records that a revision of a given menu exists, who made it, when, and whether it is the working draft or a published revision. - The
menu_revision_linktable pins each menu item in that snapshot to a specificmenu_link_contentrevision ID. This is what makes title and URL changes revision-specific, and it is why themenu_link_revisionsdependency exists. - The
menu_revision_hierarchytable records the shape of the menu at that moment: parent, weight, enabled flag and plugin ID per item.
Rendering a revision means loading those pinned link revisions and rebuilding the tree from the stored hierarchy rows, rather than reading the live menu:
/** @var \Drupal\menu_revisions\Services\MenuRevisionManagerInterface $manager */
$manager = \Drupal::service('menu_revisions.manager');
// Snapshot the live menu into a new revision (status 0 = draft).
$revision_id = $manager->createRevisionFromMenu('main');
// Rebuild the nested tree array that revision represents.
$tree = $manager->generateMenuFromRevision('main', $revision_id);
// Make that revision the live one.
$manager->publishDraftMenu('main');Requirements
From menu_revisions.info.yml:
name: 'Menu Revisions'
type: module
description: 'Provides whole-menu revisions with draft and publish workflow.'
package: Menu
core_version_requirement: ^10 || ^11
dependencies:
- drupal:menu_link_content
- drupal:menu_ui
- drupal:text
- drupal:menu_link_revisionsOnly menu_link_content menus can be revisioned. MenuRevisionManager::isMenuRevisionable() walks the menu tree and returns FALSE as soon as it finds a link whose plugin ID does not start with menu_link_content:. Menus containing module-defined links (for example the core administration menu) are rejected, and the settings form reports "Unable to save the items due to incompatible menu."
Menu Revision Settings
The module provides a configuration form at:
/admin/config/menu-revisions/settingsThis form allows administrators to choose which Drupal menus should be managed by Entire Menu Revisions. It is also reachable from Configuration › Content authoring › Menu Revisions Settings.
Selecting menus
The settings form lists the menus available on the Drupal site as a checkbox set. Administrators can select the menus that should participate in the revision workflow. One or more menus can be selected.
The selection is stored in the simple config object menu_revisions.settings:
# config/install/menu_revisions.settings.yml
selected_menu: []With two menus enabled, the exported config looks like this:
# menu_revisions.settings.yml
selected_menu:
main: main
footer: footerWhich can also be set from Drush:
drush config:set menu_revisions.settings selected_menu.main main
drush config:set menu_revisions.settings selected_menu.footer footerEvery other part of the module gates on this one value. The check is centralised in the helper in menu_revisions.module:
/**
* Helper function to check if a menu has revision tracking enabled.
*/
function _menu_revisions_is_enabled($menu_id) {
$config = \Drupal::config('menu_revisions.settings');
// Handle menu entity objects.
if (is_object($menu_id) && method_exists($menu_id, 'id')) {
$menu_id = $menu_id->id();
}
$selected_menus = $config->get('selected_menu');
return is_array($selected_menus) && in_array($menu_id, $selected_menus, TRUE);
}Think of this setting as the scope of the module. The selected menus are the menus for which Entire Menu Revisions provides draft, preview, revision history, publishing, and revert functionality.
Menus selected for revisioning
When a menu is selected and the configuration is saved, MenuRevisionAdminForm::submitForm() bootstraps that menu into the workflow: if the menu has no revisions yet, an initial revision is created from the current live menu and immediately published, so the menu always has a published baseline to fall back on.
foreach ($selected_menus as $menu_id) {
// Create and publish a baseline revision the first time a menu is enabled.
if ($menu_revision_service->getLatestRevision($menu_id) == NULL) {
$menu_revision_service->createRevisionFromMenu($menu_id);
$menu_revision_service->publishDraftMenu($menu_id);
}
if (!$menu_revision_service->isMenuRevisionable($menu_id)) {
$this->messenger()->addError($this->t('Unable to save the items due to incompatible menu.'));
return;
}
}Changes to a configured menu can therefore be managed through the menu revision workflow, including:
- Saving a draft.
- Publishing a revision.
- Viewing revision history.
- Previewing a draft.
- Reverting a previous revision.
- Adding and removing menu items.
- Changing menu item details.
- Changing menu item hierarchy.
- Reordering menu items.
- Enabling or disabling menu items.
Menus not selected for revisioning
Menus that are not selected in the settings form are not included in the Entire Menu Revisions workflow. Every hook implementation returns early for them, the extra local tasks are removed, and the standard Edit menu form is left untouched.
This allows a site to use the module selectively instead of applying menu revision management to every menu.
Review the configured menu selection carefully. The menu selection determines which menus participate in the revision workflow. Add only the menus that should be managed through Entire Menu Revisions.
Changing the menu selection
The selected menus can be changed through the same settings form. An administrator can add another menu to the configuration or remove a previously selected menu as the site's requirements change. The settings page should therefore be treated as the central configuration point for deciding which menus are managed by the module.
Database tables
The module stores data in three tables: one created by the menu_revision content entity, and two declared in hook_schema() in menu_revisions.install.
menu_revision
The base table of the menu_revision content entity. One row per revision of one menu.
| Column | Type | Description |
|---|---|---|
id |
serial | Revision ID (the entity ID). |
uuid |
varchar | Entity UUID. |
menu_name |
varchar(64) | Machine name of the menu this revision belongs to. |
label |
varchar(255) | Revision label, generated as "Revision from Y-m-d H:i:s". |
uid |
int | Author — the user who created the revision. |
is_default |
boolean | 1 for the current working revision (what the draft screen edits). Exactly one per menu. |
status |
boolean | 0 = draft, 1 = published. |
description |
text_long | Optional description of the revision. |
created / changed |
timestamp | Creation and last-edit times. |
is_default and status are independent. is_default marks the revision the draft form writes to; status marks whether a revision has ever been published. A freshly published revision is both. An unpublished draft is is_default = 1, status = 0, while the menu visitors see is the newest row with status = 1.
menu_revision_link
Maps menu links to specific menu revisions — the pinning table.
$schema['menu_revision_link'] = [
'description' => 'Maps menu links to specific menu revisions.',
'fields' => [
'id' => ['type' => 'serial', 'not null' => TRUE],
'menu_revision_id' => ['type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE],
'menu_link_content_id' => ['type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE],
'menu_link_revision_id' => ['type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE],
],
'primary key' => ['id'],
'indexes' => [
'menu_revision' => ['menu_revision_id'],
'menu_link_content' => ['menu_link_content_id'],
],
'foreign keys' => [
'menu_revision' => [
'table' => 'menu_revision',
'columns' => ['menu_revision_id' => 'id'],
],
'menu_link_content' => [
'table' => 'menu_link_content',
'columns' => ['menu_link_content_id' => 'id'],
],
],
];menu_link_revision_id is the key column: it points at a row in core's menu_link_content_revision table, so the title, URL and other field values recorded by this snapshot are frozen even after the live link is edited again.
menu_revision_hierarchy
Stores the shape of the menu for a revision — one row per item.
| Column | Type | Description |
|---|---|---|
id |
serial | Primary identifier. |
menu_revision_id |
int | The menu_revision entity ID. |
menu_link_content_id |
int | The menu link entity ID. |
uuid |
varchar(128) | UUID of the menu link — the key the tree is rebuilt on. |
parent |
varchar(255) | Parent plugin ID, e.g. menu_link_content:<uuid>; empty for top-level items. |
weight |
int | Ordering within the parent. |
enabled |
int | 1 = enabled, 0 = disabled. |
plugin_id |
varchar(255) | Plugin ID of the link. |
Indexes are declared on menu_revision_id and uuid. Hierarchy rows are written by MenuHierarchyManager::captureMenuHierarchy():
$this->database->insert('menu_revision_hierarchy')
->fields([
'menu_revision_id' => $menu_revision_id,
'menu_link_content_id' => $link->id(),
'uuid' => $uuid,
'parent' => $link->getParentId(),
'weight' => $link->getWeight(),
'plugin_id' => 'menu_link_content:' . $uuid,
])
->execute();and read back, keyed by UUID, by MenuHierarchyManager::getMenuHierarchy().
Update hook 10002. menu_revisions_update_10001() created menu_revision_hierarchy without the enabled column that hook_schema() declares. menu_revisions_update_10002() adds the missing column on sites affected by that. Run drush updatedb after updating the module.
Both custom tables are dropped on uninstall:
/**
* Implements hook_uninstall().
*/
function menu_revisions_uninstall() {
\Drupal::database()->schema()->dropTable('menu_revision_link');
\Drupal::database()->schema()->dropTable('menu_revision_hierarchy');
}Inspecting a revision by hand
-- Every revision of the main menu, newest first.
SELECT id, label, status, is_default, FROM_UNIXTIME(created) AS created
FROM menu_revision
WHERE menu_name = 'main'
ORDER BY id DESC;
-- What the currently published revision of "main" contains.
SELECT h.uuid, h.parent, h.weight, h.enabled, l.menu_link_revision_id
FROM menu_revision_hierarchy h
JOIN menu_revision_link l
ON l.menu_revision_id = h.menu_revision_id
AND l.menu_link_content_id = h.menu_link_content_id
WHERE h.menu_revision_id = (
SELECT MAX(id) FROM menu_revision WHERE menu_name = 'main' AND status = 1
)
ORDER BY h.parent, h.weight;Services
# menu_revisions.services.yml
services:
menu_revisions.manager:
class: Drupal\menu_revisions\Services\MenuRevisionManager
arguments: ['@entity_type.manager', '@current_user', '@database', '@logger.factory', '@menu_revisions.hierarchy_manager']
menu_revisions.hierarchy_manager:
class: Drupal\menu_revisions\Services\MenuHierarchyManager
arguments: ['@entity_type.manager', '@database', '@logger.factory']
menu_revisions.route_subscriber:
class: Drupal\menu_revisions\EventSubscriber\MenuEditRedirectSubscriber
tags:
- { name: event_subscriber }
menu_revisions.menu_tree_draft_item_filter_manipulator:
class: Drupal\menu_revisions\Manipulator\MenuLinkDraftManipulator
arguments: ['@menu_revisions.manager', '@current_route_match', '@config.factory']
tags:
- { name: menu_tree_manipulator }
Drupal\menu_revisions\Hook\MenuRevisionsHooks:
class: Drupal\menu_revisions\Hook\MenuRevisionsHooks
autowire: truemenu_revisions.manager
Drupal\menu_revisions\Services\MenuRevisionManagerInterface is the public API of the module:
| Method | What it does |
|---|---|
createRevisionFromMenu($menu_name, $status = 0) |
Snapshots the live menu into a new menu_revision entity, captures link revisions and hierarchy, and returns the new revision ID. Runs in a database transaction. |
captureMenuLinkRevisions($menu_name, $menu_revision_id) |
Writes one menu_revision_link row per enabled link in the menu. |
getDefaultRevision($menu_name) |
Loads the working revision (is_default = 1), or NULL. |
getLatestPublishedRevision($menu_name) |
Loads the newest revision with status = 1. |
getLatestActiveMenuRevision($menu_name) |
Same, but returns just the ID. |
getLatestRevision($menu_name) |
Newest revision ID by created, published or not. |
publishDraftMenu($menu_name) |
Sets status = 1 on the working revision, then rebuilds the menu link plugins and router and clears menu/render caches. |
revertMenuToRevision($menu_revision_id) |
Rewrites the live menu_link_content entities to match the stored revision, in a transaction. |
generateMenuFromRevision($menu_name, $revision_id) |
Returns a nested render-ready tree array (title, url, below, weight …) for that revision. |
isMenuRevisionable($menu_id) |
FALSE if the menu contains any non-menu_link_content link. |
deleteDraftMenuLinkRevision($menu, $menu_link_content_id) |
Disables the live link and removes its row from the working revision. |
cleanDefaultStatusForItemsNotInMenuRevisionID($id) |
Clears is_default on every revision except the given one. |
Creating a revision is transactional, so a partially captured snapshot is never left behind:
public function createRevisionFromMenu($menu_name, $status = 0): ?int {
$transaction = $this->database->startTransaction('menu_revision_create');
try {
$storage = $this->entityTypeManager->getStorage('menu_revision');
// Only one revision may be the working revision.
$existing_default = $this->getDefaultRevision($menu_name);
if ($existing_default) {
$existing_default->setDefault(FALSE);
$storage->save($existing_default);
}
$menu_revision = $storage->create([
'menu_name' => $menu_name,
'label' => $this->t('Revision from @date', ['@date' => date('Y-m-d H:i:s')]),
'uid' => $this->currentUser->id(),
'is_default' => TRUE,
'status' => $status,
]);
$storage->save($menu_revision);
// Pin link revisions, then record the tree shape.
$this->captureMenuLinkRevisions($menu_name, $menu_revision->id());
$this->hierarchyManager->captureMenuHierarchy($menu_name, $menu_revision->id());
return $menu_revision->id();
}
catch (\Exception $e) {
$transaction->rollBack();
$this->logger->error('Failed to create menu revision: @message', ['@message' => $e->getMessage()]);
throw $e;
}
}Only enabled links are captured. Both captureMenuLinkRevisions() and captureMenuHierarchy() query with ->condition('enabled', TRUE). Disabling an item and saving is therefore how an item is removed from the next revision — it stays intact in the revisions taken while it was still enabled.
The menu tree manipulator
MenuLinkDraftManipulator is tagged menu_tree_manipulator and is what keeps draft changes off the live site. It decides which revision a given request should render:
$route_name = $this->routeMatch->getRouteName();
$use_draft_revision = in_array($route_name, [
'entity.menu.draft',
'entity.menu.active_edit',
], TRUE);
if ($use_draft_revision) {
$target_revision = $this->menuRevisionManager->getDefaultRevision($menu_id);
}
else {
$target_revision = $this->menuRevisionManager->getLatestPublishedRevision($menu_id);
}It then drops any tree element whose UUID is not present in the target revision, and — when rendering a published revision — overrides each link's options from the stored revision without persisting them:
// Pass FALSE so nothing is persisted and no cache rebuild is triggered;
// this is a render-time override only.
$element->link->updateLink(['options' => $options], FALSE);Hook implementations
Hooks live in Drupal\menu_revisions\Hook\MenuRevisionsHooks using the Drupal 11 #[Hook] attribute, with #[LegacyHook] procedural wrappers in menu_revisions.module for Drupal 10:
// menu_revisions.module
#[LegacyHook]
function menu_revisions_entity_type_build(array &$entity_types) {
\Drupal::service(MenuRevisionsHooks::class)->entityTypeBuild($entity_types);
}
// src/Hook/MenuRevisionsHooks.php
#[Hook('entity_type_build')]
public function entityTypeBuild(array &$entity_types) {
if (isset($entity_types['menu'])) {
$entity_types['menu']->setFormClass('draft', 'Drupal\menu_revisions\Form\MenuDraftForm');
}
}| Hook | |
|---|---|
hook_entity_type_build() |
Registers the draft form operation on the menu entity type, pointing at MenuDraftForm. |
hook_menu_link_operations_alter() |
Appends ?is_draft=true to the edit, add-child and delete operations while editing a draft. |
hook_form_menu_link_content_form_alter() |
Adds the "DRAFT MENU ITEM" indicator, disables the Enabled checkbox, removes the Delete action, and appends menu_revisions_menu_link_content_update_submit to the save handlers. |
hook_menu_local_tasks_alter() |
Hides the Draft, Delete Active Menu and Revisions tabs on unconfigured or incompatible menus, and hides the core Edit menu tab on configured ones. |
hook_preprocess_block() |
Adds the route cache context, and on the preview route sets max-age = 0 plus a menu_revision:<id> cache tag. |
hook_header_footer_management_menu_tree_alter() |
Integration with the Header/Footer Management module: replaces the supplied tree with the requested — or latest published — revision. |
The block preprocess implementation is what stops a preview being served from cache:
#[Hook('preprocess_block')]
public function preprocessBlock(&$variables) {
$route_name = \Drupal::routeMatch()->getRouteName();
$variables['#cache']['contexts'][] = 'route';
if ($route_name === 'menu_revisions.revision_preview') {
$menurevisionid = \Drupal::routeMatch()->getParameter('menu_revision_id');
$variables['#cache']['max-age'] = 0;
$variables['#cache']['tags'][] = 'menu_revision:' . $menurevisionid;
}
}Saving a menu item from inside a draft also triggers a fresh revision, via the extra submit handler:
/**
* Custom submit handler for menu link content update.
*/
function menu_revisions_menu_link_content_update_submit($form, FormStateInterface $form_state) {
$menuManager = \Drupal::service('menu_revisions.manager');
$menu_name = $form_state->getFormObject()->getEntity()->menu_name->value;
$menu_revision_id = $menuManager->createRevisionFromMenu($menu_name);
if ($menu_revision_id) {
$menuManager->cleanDefaultStatusForItemsNotInMenuRevisionID($menu_revision_id);
}
else {
\Drupal::messenger()->addError(t('Failed to create a new menu revision when updating the menu item.'));
}
}Routes and tabs
| Route | Path | Permission |
|---|---|---|
entity.menu.draft |
/admin/structure/menu/manage/{menu}/draft |
edit draft menus+administer menu revisions |
entity.menu.active_edit |
/admin/structure/menu/manage/{menu}/active/edit |
administer menu revisions |
entity.menu_revisions.collection |
/admin/structure/menu/manage/{menu}/revisions |
administer menu revisions |
entity.menu_revisions.view_structure |
/admin/structure/menu/manage/{menu}/revisions/{menu_revision}/view |
view menu revisions |
entity.menu_revisions.revert_form |
/admin/structure/menu/manage/{menu}/revisions/{menu_revision}/revert |
administer menu revisions |
menu_revisions.revision_preview |
/menu/manage/{menu}/revisions/{menu_revision_id}/preview |
edit draft menus+administer menu revisions |
menu_revisions.revision_preview_all_menus |
/menu/manage/theme/preview/{menu_revision_ids} |
edit draft menus+administer menu revisions |
menu_revisions.delete_draft_menu_item_revision |
/admin/structure/menu/{menu}/draft/item/{menu_link_content_id}/delete |
edit draft menus+administer menu revisions |
entity.menu_revisions.admin_form |
/admin/config/menu-revisions/settings |
administer site configuration |
The preview routes deliberately opt out of caching:
menu_revisions.revision_preview:
path: '/menu/manage/{menu}/revisions/{menu_revision_id}/preview'
defaults:
_controller: '\Drupal\menu_revisions\Controller\MenuRevisionAdvancedController::revisionPreview'
_title: 'Menu Revision Preview'
requirements:
_permission: 'edit draft menus+administer menu revisions'
menu_revision_id: '\d+'
options:
no_cache: TRUE
_no_big_pipe: TRUEThe core Edit menu form is redirected away. MenuEditRedirectSubscriber listens on KernelEvents::REQUEST; when the route is entity.menu.edit_form for a configured, revisionable menu, it responds with a redirect to entity.menu.draft so editors cannot bypass the workflow. If the menu is configured but not revisionable, it leaves the form alone and shows a warning.
Permissions
# menu_revisions.permissions.yml
administer menu revisions:
title: 'Administer menu revisions (Publish)'
description: 'Publish menu changes and access menu revision administration. This is a powerful permission that allows users to make menu changes live.'
restrict access: TRUE
edit draft menus:
title: 'Edit draft menus'
description: 'Create and edit draft versions of menus without publishing them. Users can modify menu structure and items in draft mode.'
view menu revisions:
title: 'View menu revisions'
description: 'View all menu revisions.'
revert menu revisions:
title: 'Revert menu revisions'
description: 'Ability to revert menu revisions to previous states.'
delete menu revisions:
title: 'Delete menu revisions'
description: 'Ability to delete menu revisions.'
restrict access: TRUEThe split matters on the draft screen: the Save and Publish button is only rendered for users holding administer menu revisions, while Preview is available to anyone holding either that or edit draft menus.
$actions['publish'] = [
'#type' => 'submit',
'#value' => $this->t('Save and Publish'),
'#button_type' => 'primary',
'#weight' => 5,
'#submit' => ['::publishSubmit'],
'#access' => $this->currentUser()->hasPermission('administer menu revisions'),
];A pure-editor role gets edit draft menus only: it can build and preview drafts but never make them live. Grant administer menu revisions to the role that approves and publishes.
Revision states
Draft
A draft is a working revision that has not yet become the live version of the menu — a menu_revision row with is_default = 1 and status = 0.
- Saving a draft creates a new revision.
- The revision appears in the revision list.
- The draft can be opened and edited again.
- The published menu remains unchanged.
- The draft can be previewed before publishing.
Published
When a draft is saved and published:
- A new revision is created.
- The revision becomes the active published revision (
status = 1). - The live menu reflects the published menu structure.
- Previous revisions remain available for review.
publishDraftMenu() flips the flag and then flushes everything that could still be serving the old tree:
$default_revision->setStatus(1);
$this->entityTypeManager->getStorage('menu_revision')->save($default_revision);
\Drupal::service('plugin.manager.menu.link')->rebuild();
\Drupal::service('router.builder')->rebuild();
Cache::invalidateTags(['menu:' . $menu_name]);
\Drupal::service('cache.menu')->deleteAll();Revision IDs are identifiers, not version numbers. menu_revision.id is a shared serial across all menus, and editing a single menu item creates a revision of its own — so IDs should not be expected to increment by exactly 1 for every revision of a given menu.
Saving a draft
The draft screen's Save Draft button saves the menu entity, then snapshots it:
public function saveDraftSubmit(array &$form, FormStateInterface $form_state) {
$menu = $this->entity;
if (!$this->validateMenuForUpdate($menu->links)) {
return;
}
$menu->save();
$this->saveDraftData($form, $form_state);
}The revision list should show the new draft and indicate which revision represents the current working state. The draft should contain:
- The correct menu item order.
- The correct menu item titles.
- The correct parent-child relationships.
- The correct enabled state.
- The current menu item details stored by the revision.
Editing a draft
A saved draft can be opened again from the revision list. When the draft is opened for editing, MenuDraftForm rebuilds the tree from the working revision's hierarchy rows rather than from the live menu.
- Menu items should appear in the correct order.
- Menu item titles should match the draft.
- Parent-child relationships should match the draft.
- Changes belonging to later revisions should not appear in the older draft.
Creating a new menu item
A new menu item can be created from the menu edit screen using the top-right add menu item action, declared as a local action that carries the draft flag:
# menu_revisions.links.action.yml
menu_revisions.draft_add_link_form:
route_name: entity.menu.add_link_form
title: 'Add link'
class: \Drupal\menu_ui\Plugin\Menu\LocalAction\MenuLinkAdd
appears_on:
- entity.menu.draft
options:
query:
is_draft: trueAfter the item is created:
- The item should exist in the new draft.
- Previous revisions should not contain the new item.
- The published menu should not show the item until the draft is published.
- Anonymous users should continue to see the currently published menu.
- The draft preview should display the new item.
- The Enabled checkbox is disabled in the add menu item window.
That last point is enforced in the form alter:
if (isset($form['enabled'])) {
$form['enabled']['#disabled'] = TRUE;
$form['enabled']['#description'] = $this->t('Menu items in draft mode are always enabled. You can disable the menu via the main menu draft edit window.');
}Historical revisions remain unchanged. Creating a new menu item in a draft must not add that item to revisions that existed before the item was created — the older revisions have no menu_revision_link or menu_revision_hierarchy row for it.
Creating a child menu item
A new menu item can be created using the add-child action from an existing menu item. hook_menu_link_operations_alter() keeps the draft context attached to that link:
#[Hook('menu_link_operations_alter')]
public function menuLinkOperationsAlter(array &$operations, MenuLinkInterface $menu_link) {
if (!_menu_revisions_is_enabled($menu_link->getMenuName())) {
return;
}
if (\Drupal::routeMatch()->getParameter('is_draft')) {
foreach (['edit', 'add-child', 'delete'] as $operation) {
if (isset($operations[$operation])) {
$operations[$operation]['url'] = $operations[$operation]['url']
->setOption('query', ['is_draft' => 'true']);
}
}
}
}- The new item should be created under the selected parent.
- The new parent-child relationship should be visible in the menu edit screen.
- Previous revisions should not contain the new item.
- The published menu should not show the item until the draft is published.
- The draft preview should show the new item in the correct hierarchy.
- An item created with Enabled = FALSE should not be shown as an enabled menu item.
Reverting removes later additions from the working state. If an older revision did not contain the new child item, reverting to that revision must not leave the child item in the resulting draft.
Disabling an existing menu item in a draft
When an existing menu item is disabled and the change is saved as a draft:
- The item should no longer be available in the new draft — the capture queries skip disabled links, so no rows are written for it.
- Older revisions that contained the item should continue to contain it.
- The live menu should remain unchanged.
Disabling an existing menu item and publishing
When the disabled state is saved and published:
- The item should no longer be available in the newly published revision.
- Older revisions should continue to contain the item where it previously existed.
- The live menu should reflect the disabled state.
Deleting a menu item
Deleting a menu item from the menu edit screen removes it from the current draft. The module routes this through its own controller rather than core's delete form, so nothing is destroyed:
public function deleteDraftMenuLinkRevision($menu, $menu_link_content_id): void {
$default_revision = $this->getDefaultRevision($menu)->id();
$menu_link = $this->entityTypeManager->getStorage('menu_link_content')->load($menu_link_content_id);
if ($menu_link) {
$menu_link->set('enabled', FALSE);
$menu_link->save();
}
// Remove the item from the working revision only.
$this->database->delete('menu_revision_link')
->condition('menu_revision_id', $default_revision)
->condition('menu_link_content_id', $menu_link_content_id)
->execute();
}- The item should no longer appear in the draft.
- Previous revisions that already contained the item should continue to show it.
- Deleting the item from the draft should not modify historical revisions.
"Delete" in a draft means "disable and unpin". The menu_link_content entity survives, which is what allows older revisions to keep resolving their pinned link revisions. For the same reason, the Delete action is removed from the menu link form while editing in draft mode.
Deleting a menu item that has children
When a menu item with children is deleted, the parent item is removed and the children move up one level in the menu hierarchy.
Example: If Products contains Product A and Product B, deleting Products removes that parent while Product A and Product B move up one level. Their parent values in the next menu_revision_hierarchy snapshot become empty.
Updating menu item details
Changes to the details of a menu item are stored as part of the revision in which they were made. Saving the item creates a new menu_link_content revision, and the submit handler added by hook_form_menu_link_content_form_alter() immediately snapshots a new menu revision pinned to it.
- The updated values should be visible when the item is edited again.
- The updates should remain available in revisions where they were saved.
- If the menu is reverted to a revision that did not contain the updates, those updates should no longer appear in the resulting draft.
- Updating the parent of a menu item should be reflected in the menu draft edit screen.
Historical revisions must remain independent. Updating an item in a newer revision must not rewrite the item data represented by an older revision — the older menu_revision_link row still points at the earlier menu_link_revision_id.
Reordering menu items
Menu item ordering is part of the menu revision — the weight column of menu_revision_hierarchy.
- The new order should be reflected in the draft after saving.
- The current published menu should retain its existing order until the draft is published.
- The order stored in previous revisions should not change.
Revision isolation: Reordering the current draft inserts new hierarchy rows for the new revision; it does not update rows belonging to earlier revisions.
Previewing a draft
The Preview button on the draft screen links to the preview route for the working revision, opening in a new tab:
$actions['preview'] = [
'#type' => 'link',
'#title' => $this->t('Preview'),
'#attributes' => ['class' => ['button'], 'target' => '_blank'],
'#weight' => 5,
'#url' => Url::fromRoute('menu_revisions.revision_preview', [
'menu' => $this->entity->id(),
'menu_revision_id' => $revisionID,
]),
'#access' => $can_preview,
];The preview should represent the menu as it exists in the draft revision. This includes:
- New menu items.
- Removed menu items.
- Updated menu item details.
- Updated parent-child relationships.
- Updated ordering.
- The enabled or disabled state represented by the draft.
Several revisions can be previewed together — for example a header and a footer menu — with the comma-separated variant:
/menu/manage/theme/preview/12,15Visitors continue to see the published menu. Previewing a draft does not replace the menu that anonymous users see: outside the two draft routes, MenuLinkDraftManipulator always renders the latest published revision.
Reverting to an older revision
Reverting an older revision creates a new working state based on the selected historical revision, from the confirm form at:
/admin/structure/menu/manage/{menu}/revisions/{menu_revision}/revertrevertMenuToRevision() reads the revision's pinned links and hierarchy, then rewrites the live menu_link_content entities to match — matching by UUID, so items are updated rather than duplicated:
// Get all menu links pinned to this revision.
$revision_links = $this->database->select('menu_revision_link', 'mrl')
->fields('mrl')
->condition('menu_revision_id', $menu_revision_id)
->execute()
->fetchAllAssoc('menu_link_content_id');
// Get the shape of the menu at that point.
$hierarchy_data = $this->hierarchyManager->getMenuHierarchy($menu_revision_id);
if (empty($revision_links) || empty($hierarchy_data)) {
$this->logger->warning('Insufficient data for revision @id', ['@id' => $menu_revision_id]);
return FALSE;
}The resulting draft represents the menu structure contained in the selected revision, restoring the appropriate:
- Menu items.
- Menu item details.
- Parent-child relationships.
- Enabled or disabled state.
- Menu item ordering.
Revert does not rewrite history. The original historical revision remains available. The reverted state becomes a new working revision rather than modifying the old one. The same method is also used by Save and Publish to apply a newly published revision to the live menu.
Revision history
The revision history at /admin/structure/menu/manage/{menu}/revisions allows users to review the different states of a configured menu over time, with links to view each revision's structure, preview it, and revert to it.
Historical revisions continue to represent the structure that existed when they were created. For example, when a menu item is created in a later revision:
- Older revisions should not contain the item.
- The revision in which it was created should contain it.
- A later revision may remove or disable the item without changing earlier revisions.
Expected behavior summary
Revision behavior at a glance
Each revision represents its own menu state. Newer changes should not rewrite older revisions, and reverting should restore the selected revision as the basis for a new working state.
- Only menus selected in
/admin/config/menu-revisions/settingsparticipate in the revision workflow. - Only menus built entirely from
menu_link_contentlinks can be revisioned. - Saving a draft creates a new revision (
is_default = 1,status = 0). - Publishing creates an active published revision (
status = 1). - The live menu remains unchanged while working on an unpublished draft.
- New items appear only in revisions where they exist.
- Deleted items remain available in historical revisions where they existed.
- Disabled items remain available in revisions where they were previously enabled.
- Menu item detail changes belong to the revisions where they were saved, via the pinned
menu_link_revision_id. - Parent-child relationships are revision-specific (
menu_revision_hierarchy.parent). - Menu ordering is revision-specific (
menu_revision_hierarchy.weight). - Reordering a draft does not reorder historical revisions.
- Deleting a parent moves its children up one level.
- Reverting creates a new working state based on the selected revision.
- Historical revisions remain unchanged.
- Revision IDs should not be treated as sequential version numbers.
Related modules
Related modules
Modules that complement Entire Menu Revisions when managing Drupal menu links and menu history.
- Menu Link Revisions, revision management for individual menu links. Required by this module.
Installation
Install the project with Composer:
composer require 'drupal/entire_menu_revisions:^1.0'Enable the module with Drush — note the module machine name is menu_revisions:
drush en menu_revisions
drush updatedbYou can also enable Menu Revisions through the Drupal administration interface at /admin/modules.
Uninstallation
To completely remove the menu revision data created by the module, first delete the revision entities:
drush entity:delete menu_revisionThen uninstall the module, which drops menu_revision_link and menu_revision_hierarchy:
drush pm:uninstall menu_revisionsWarning: Deleting the revision entities removes the revision history managed by the module, and uninstalling drops both custom tables. Make sure this data is no longer required before performing the cleanup.
Support and contributions
For bug reports, feature requests, documentation improvements, testing, or contributions, please use the project's issue queue on Drupal.org.
Help improve this page
You can:
- Log in, click Edit, and edit this page
- Log in, click Discuss, update the Page status value, and suggest an improvement
- Log in and create a Documentation issue with your suggestion