diff --git a/src/wp-includes/nav-menu-template.php b/src/wp-includes/nav-menu-template.php
index e1a58b06689c7..135d35e13024f 100644
--- a/src/wp-includes/nav-menu-template.php
+++ b/src/wp-includes/nav-menu-template.php
@@ -638,15 +638,38 @@ function _nav_menu_item_id_use_once( $id, $item ) {
/**
* Remove the `menu-item-has-children` class from bottom level menu items.
*
+ * This runs on the {@see 'nav_menu_css_class'} filter. The $args and $depth
+ * parameters were added after the filter was originally introduced in
+ * WordPress 3.0.0 so this needs to allow for cases in which the filter is
+ * called without them.
+ *
+ * @see https://core.trac.wordpress.org/ticket/56926.
+ *
* @since 6.1.2
*
- * @param string[] $classes Array of the CSS classes that are applied to the menu item's `
` element.
- * @param WP_Post $menu_item The current menu item object.
- * @param stdClass $args An object of wp_nav_menu() arguments.
- * @param int $depth Depth of menu item.
+ * @param string[] $classes Array of the CSS classes that are applied to the menu item's `` element.
+ * @param WP_Post $menu_item The current menu item object.
+ * @param stdClass|false $args An object of wp_nav_menu() arguments. Default false ($args unspecified when filter is called).
+ * @param int|false $depth Depth of menu item. Default false ($depth unspecified when filter is called).
* @return string[] Modified nav menu classes.
*/
-function wp_nav_menu_remove_menu_item_has_children_class( $classes, $menu_item, $args, $depth ) {
+function wp_nav_menu_remove_menu_item_has_children_class( $classes, $menu_item, $args = false, $depth = false ) {
+ /*
+ * Account for the filter being called without the $args or $depth parameters.
+ *
+ * This occurs when a theme uses a custom walker calling the `nav_menu_css_class`
+ * filter using the legacy formats prior to the introduction of the $args and
+ * $depth parameters.
+ *
+ * As both of these parameters are required for this function to determine
+ * both the current and maximum depth of the menu tree, the function does not
+ * attempt to remove the `menu-item-has-children` class if these parameters
+ * are not set.
+ */
+ if ( false === $depth || false === $args ) {
+ return $classes;
+ }
+
// Max-depth is 1-based.
$max_depth = isset( $args->depth ) ? (int) $args->depth : 0;
// Depth is 0-based so needs to be increased by one.
diff --git a/tests/phpunit/tests/menu/wpNavMenuRemoveMenuItemHasChildrenClass.php b/tests/phpunit/tests/menu/wpNavMenuRemoveMenuItemHasChildrenClass.php
new file mode 100644
index 0000000000000..535eba068e3f0
--- /dev/null
+++ b/tests/phpunit/tests/menu/wpNavMenuRemoveMenuItemHasChildrenClass.php
@@ -0,0 +1,100 @@
+ $classes,
+ );
+
+ $args = (object) array(
+ 'depth' => 2,
+ );
+
+ $depth = 2;
+
+ $class_names = implode( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $menu_item ) );
+ $this->assertStringContainsString( 'menu-item-has-children', $class_names, 'Class name should be retained when filter is called with two arguments.' );
+ $class_names = implode( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $menu_item, $args ) );
+ $this->assertStringContainsString( 'menu-item-has-children', $class_names, 'Class name should be retained when filter is called with three arguments.' );
+ $class_names = implode( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $menu_item, $args, $depth ) );
+ $this->assertStringNotContainsString( 'menu-item-has-children', $class_names, 'Class name should not be retained when filter is called with four arguments.' );
+ }
+
+ /**
+ * Ensure menu-item-has-children class is removed or retained as expected.
+ *
+ * @dataProvider data_menu_item_has_children_class_should_be_removed_or_retained_as_expected
+ * @ticket 56926
+ */
+ public function test_menu_item_has_children_class_should_be_removed_or_retained_as_expected( $args, $depth, $should_be_retained ) {
+ $classes = array( 'menu-item-has-children', 'menu-item', 'menu-item-123' );
+
+ $menu_item = (object) array(
+ 'classes' => $classes,
+ );
+
+ $class_names = wp_nav_menu_remove_menu_item_has_children_class( $classes, $menu_item, $args, $depth );
+ if ( $should_be_retained ) {
+ $this->assertContains( 'menu-item-has-children', $class_names, 'Class name should be retained.' );
+ return;
+ }
+
+ $this->assertNotContains( 'menu-item-has-children', $class_names, 'Class name should not be retained.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_menu_item_has_children_class_should_be_removed_or_retained_as_expected() {
+ return array(
+ 'Depth not set' => array(
+ 'args' => (object) array( 'depth' => 1 ),
+ 'depth' => false,
+ 'should_be_retained' => true,
+ ),
+ 'Neither depth nor args set' => array(
+ 'args' => false,
+ 'depth' => false,
+ 'should_be_retained' => true,
+ ),
+ 'Max depth is set to minus 1' => array(
+ 'args' => (object) array( 'depth' => -1 ),
+ 'depth' => 1,
+ 'should_be_retained' => false,
+ ),
+ 'Max depth is set to zero' => array(
+ 'args' => (object) array( 'depth' => 0 ),
+ 'depth' => 1,
+ 'should_be_retained' => true,
+ ),
+ 'Item depth exceeds max depth' => array(
+ 'args' => (object) array( 'depth' => 2 ),
+ 'depth' => 3,
+ 'should_be_retained' => false,
+ ),
+ 'Item depth is lower than max depth' => array(
+ 'args' => (object) array( 'depth' => 5 ),
+ 'depth' => 3,
+ 'should_be_retained' => true,
+ ),
+ 'Item depth is one lower than max depth' => array(
+ 'args' => (object) array( 'depth' => 2 ),
+ 'depth' => 1,
+ 'should_be_retained' => false, // Depth is zero-based, max depth is not.
+ ),
+ );
+ }
+}