2026年11月12日
2026年11月12日
Customize WordPress Nav Menus: Filters, Walkers and Custom Fields
This article is a localized edition of the Japanese original. Read the Japanese original
WordPress navigation menus are registered with register_nav_menus() and output with wp_nav_menu(). You can customize the HTML they produce in two ways: add classes and attributes with filters, or change how the markup is built with a class that extends Walker_Nav_Menu (a Walker).
This article covers classic themes that use functions.php and template files. The code was tested on WordPress 6.9 with PHP 8.3 (as of October 2026). The Navigation block in block themes works differently and is covered briefly at the end.
Back up functions.php before you edit it, and put your code in a child theme so a theme update does not erase it.
Register menu locations
In your theme's functions.php, call register_nav_menus() from the after_setup_theme hook. The keys (such as primary) are the names you use in templates, and the values are the descriptions shown in the admin.
<?php
add_action( 'after_setup_theme', function () {
register_nav_menus(
array(
'primary' => 'Global navigation',
'footer' => 'Footer menu',
)
);
} );
Once you register locations, the theme is treated as supporting menus and "Appearance" → "Menus" appears in the admin. After you build a menu, either check the location you want under "Menu Settings" at the bottom of the screen and click "Save Menu", or assign a menu to each location on the "Manage Locations" tab.
Display the menu with wp_nav_menu()
In a header template (such as header.php), output the menu assigned to a registered location.
<?php
if ( has_nav_menu( 'primary' ) ) {
wp_nav_menu(
array(
'theme_location' => 'primary',
'container' => 'nav',
'container_class' => 'main-navigation',
'container_aria_label' => 'Main menu',
'menu_class' => 'navbar-nav',
'depth' => 2,
'fallback_cb' => false,
)
);
}
The main arguments are:
| Argument | Meaning |
|---|---|
theme_location |
The key registered with register_nav_menus() |
container |
The wrapping tag: nav or div, or false for no wrapper |
container_aria_label |
The aria-label added when container is nav |
menu_class |
The class of the ul element |
depth |
How many levels to display. 0 means no limit |
fallback_cb |
The function called when no menu is found. The default is wp_page_menu, which lists your pages. false outputs nothing |
items_wrap |
The format of the ul. The default is <ul id="%1$s" class="%2$s">%3$s</ul> (%3$s is the items) |
walker |
An instance of the Walker that builds the HTML |
has_nav_menu() returns whether a menu is assigned to that location. Wrap the call like this when you want to output nothing if no menu is assigned.
Add classes and attributes with filters (Bootstrap 5 example)
If you only need to add classes or attributes, filters are enough and you do not need a Walker. Walker_Nav_Menu passes its output through the following filters, so you can change it while keeping the standard structure (ul, li and a).
| Filter | Target | Arguments |
|---|---|---|
nav_menu_css_class |
Classes of the li |
$classes, $menu_item, $args, $depth |
nav_menu_link_attributes |
Attributes of the a |
$atts, $menu_item, $args, $depth |
nav_menu_submenu_css_class |
Classes of the submenu ul |
$classes, $args, $depth |
nav_menu_item_title |
The link text | $title, $menu_item, $args, $depth |
The following example adds the Bootstrap 5 navbar and dropdown classes to the menu in the primary location. It checks the location with $args->theme_location so other menus are not affected.
<?php
// Add nav-item to the li, and dropdown to items that have children
add_filter( 'nav_menu_css_class', function ( $classes, $menu_item, $args, $depth ) {
if ( 'primary' !== $args->theme_location ) {
return $classes;
}
if ( 0 === $depth ) {
$classes[] = 'nav-item';
if ( in_array( 'menu-item-has-children', $classes, true ) ) {
$classes[] = 'dropdown';
}
}
return $classes;
}, 10, 4 );
// Add nav-link / dropdown-toggle / dropdown-item and the dropdown attributes to the a
add_filter( 'nav_menu_link_attributes', function ( $atts, $menu_item, $args, $depth ) {
if ( 'primary' !== $args->theme_location ) {
return $atts;
}
if ( $depth > 0 ) {
$atts['class'] = 'dropdown-item';
return $atts;
}
$atts['class'] = 'nav-link';
if ( in_array( 'menu-item-has-children', (array) $menu_item->classes, true ) ) {
$atts['class'] .= ' dropdown-toggle';
$atts['role'] = 'button';
$atts['data-bs-toggle'] = 'dropdown';
$atts['aria-expanded'] = 'false';
}
return $atts;
}, 10, 4 );
// Add dropdown-menu to the submenu ul
add_filter( 'nav_menu_submenu_css_class', function ( $classes, $args, $depth ) {
if ( 'primary' === $args->theme_location ) {
$classes[] = 'dropdown-menu';
}
return $classes;
}, 10, 3 );
The output looks like this (partly omitted):
<ul id="menu-main" class="navbar-nav">
<li class="menu-item ... nav-item"><a href="/" class="nav-link">Home</a></li>
<li class="menu-item menu-item-has-children ... nav-item dropdown">
<a href="/services/" class="nav-link dropdown-toggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">Services</a>
<ul class="sub-menu dropdown-menu">
<li class="menu-item ..."><a href="/services/web/" class="dropdown-item">Web</a></li>
</ul>
</li>
</ul>
Two things to keep in mind:
- For the dropdown to open, your theme must load the Bootstrap CSS and JavaScript (such as
bootstrap.bundle.min.js). - In a Bootstrap dropdown, clicking the parent item only opens the menu and does not take you to the parent's link. If visitors should also reach the parent page, add that page as the first item of the submenu too.
Change the HTML structure with a Walker
When you want to change the structure itself, for example to drop the li elements, extend Walker_Nav_Menu. These are the four main methods you can override:
| Method | Called at |
|---|---|
start_lvl( &$output, $depth = 0, $args = null ) |
The start of a submenu (default <ul class="sub-menu">) |
end_lvl( &$output, $depth = 0, $args = null ) |
The end of a submenu |
start_el( &$output, $data_object, $depth = 0, $args = null, $current_object_id = 0 ) |
The start of each item (default <li> and <a>) |
end_el( &$output, $data_object, $depth = 0, $args = null ) |
The end of each item (default </li>) |
When you override them, keep the same parameters and default values as the parent class, and do not add type declarations. Adding types the parent does not have (such as string &$output or WP_Post $item) causes a fatal "must be compatible with" error from PHP's signature compatibility check.
The following Walker, meant for a footer, outputs only a elements with no li.
<?php
class My_Flat_Link_Walker extends Walker_Nav_Menu {
public function start_lvl( &$output, $depth = 0, $args = null ) {}
public function end_lvl( &$output, $depth = 0, $args = null ) {}
public function start_el( &$output, $data_object, $depth = 0, $args = null, $current_object_id = 0 ) {
$menu_item = $data_object;
$is_current = in_array( 'current-menu-item', (array) $menu_item->classes, true );
$output .= sprintf(
'<a href="%1$s"%2$s>%3$s</a>',
esc_url( $menu_item->url ),
$is_current ? ' class="is-current" aria-current="page"' : '',
esc_html( $menu_item->title )
);
}
public function end_el( &$output, $data_object, $depth = 0, $args = null ) {}
}
In the template, set items_wrap to %3$s so no ul is output, and pass the Walker in walker.
<?php
wp_nav_menu(
array(
'theme_location' => 'footer',
'container' => 'nav',
'items_wrap' => '%3$s',
'depth' => 1,
'walker' => new My_Flat_Link_Walker(),
)
);
When you write start_el() yourself, the standard classes on li and a, attributes such as target and rel, and the filters above are no longer applied. Output whatever you need yourself. If you only need extra classes or attributes, the filters in the previous section are easier to maintain than a Walker.
Add menu items based on login status
The wp_nav_menu_items filter receives the HTML of the built items (a series of li elements). Append strings to it to show items that depend on whether the visitor is logged in.
<?php
add_filter( 'wp_nav_menu_items', function ( $items, $args ) {
if ( 'primary' !== $args->theme_location ) {
return $items;
}
if ( is_user_logged_in() ) {
$items .= '<li class="menu-item nav-item"><a class="nav-link" href="' . esc_url( admin_url() ) . '">Dashboard</a></li>';
$items .= '<li class="menu-item nav-item"><a class="nav-link" href="' . esc_url( wp_logout_url( home_url( '/' ) ) ) . '">Log out</a></li>';
} else {
$redirect = is_singular() ? get_permalink() : home_url( '/' );
$items .= '<li class="menu-item nav-item"><a class="nav-link" href="' . esc_url( wp_login_url( $redirect ) ) . '">Log in</a></li>';
}
return $items;
}, 10, 2 );
The argument of wp_login_url() is where to return after logging in. Because get_permalink() cannot be used on archive pages and similar views, the code passes it only on single posts and pages. If you use a page caching plugin, pages for logged-out visitors are cached, so the logged-in items may not show up.
Add an icon field to menu items
Since WordPress 5.4, you can add input fields to each item's settings in "Appearance" → "Menus" with the wp_nav_menu_item_custom_fields action. Saving is done with the wp_update_nav_menu_item action.
The common mistake here is this: the $_POST key read in the save handler must match the field's name. If the field is name="menu-item-icon[ID]", the save handler must read $_POST['menu-item-icon'] (with a different spelling such as menu_item_icon, nothing is saved).
<?php
// 1. Show the input field
add_action( 'wp_nav_menu_item_custom_fields', function ( $item_id, $menu_item ) {
$icon = get_post_meta( $item_id, '_menu_item_icon', true );
?>
<p class="field-icon description description-wide">
<label for="edit-menu-item-icon-<?php echo esc_attr( $item_id ); ?>">
Icon class names (e.g. fa-solid fa-house)<br>
<input type="text" class="widefat"
id="edit-menu-item-icon-<?php echo esc_attr( $item_id ); ?>"
name="menu-item-icon[<?php echo esc_attr( $item_id ); ?>]"
value="<?php echo esc_attr( $icon ); ?>">
</label>
</p>
<?php
}, 10, 2 );
// 2. Save the value
add_action( 'wp_update_nav_menu_item', function ( $menu_id, $menu_item_db_id ) {
// Only run when the menu is saved from Appearance → Menus
if ( ! isset( $_POST['update-nav-menu-nonce'] )
|| ! wp_verify_nonce( wp_unslash( $_POST['update-nav-menu-nonce'] ), 'update-nav_menu' )
|| ! current_user_can( 'edit_theme_options' ) ) {
return;
}
// Read the same key as the field's name="menu-item-icon[ID]"
if ( ! isset( $_POST['menu-item-icon'][ $menu_item_db_id ] ) ) {
return;
}
$raw = wp_unslash( $_POST['menu-item-icon'][ $menu_item_db_id ] );
$classes = array_filter( array_map( 'sanitize_html_class', preg_split( '/\s+/', $raw ) ) );
if ( $classes ) {
update_post_meta( $menu_item_db_id, '_menu_item_icon', implode( ' ', $classes ) );
} else {
delete_post_meta( $menu_item_db_id, '_menu_item_icon' );
}
}, 10, 2 );
// 3. Output the icon before the link text
add_filter( 'nav_menu_item_title', function ( $title, $menu_item, $args, $depth ) {
$icon = get_post_meta( $menu_item->ID, '_menu_item_icon', true );
if ( $icon ) {
$title = '<i class="' . esc_attr( $icon ) . '" aria-hidden="true"></i> ' . $title;
}
return $title;
}, 10, 4 );
Key points of the code:
- WordPress core puts a nonce named
update-nav-menu-noncein the "Appearance" → "Menus" form and verifies it before saving. The save handler checks this nonce and updates the meta data only when the menu is saved from the Menus screen. You do not need to output your own nonce field for each item. - The input is split on whitespace, reduced with
sanitize_html_class()to characters that are safe in class names, and deleted when empty. - To display the icons, your theme must load an icon font such as Font Awesome.
- The field appears on the "Appearance" → "Menus" screen. It does not appear in the menu editor of the Customizer.
Block themes
In block themes such as Twenty Twenty-Five, you build menus with the Navigation block in the Site Editor. Those menus are saved as wp_navigation posts, and the wp_nav_menu() calls, filters and Walkers in this article are not used.
If a block theme does not call register_nav_menus(), "Appearance" → "Menus" is not shown either. When you move from a classic theme, you can import an existing classic menu from the menu selector of the Navigation block.
Summary
- Register menu locations by calling
register_nav_menus()onafter_setup_theme, and output them in templates withwp_nav_menu()and atheme_location - To add classes or attributes, the
nav_menu_css_class,nav_menu_link_attributesandnav_menu_submenu_css_classfilters are enough - To change the HTML structure, extend
Walker_Nav_Menu, keep the method parameters identical to the parent class, and do not add type declarations - Use the
wp_nav_menu_itemsfilter to change items based on login status - For custom fields on menu items, make the field's
nameand the$_POSTkey read in the save handler match
Related articles
- Fixing a WordPress navigation menu that does not work
- How to set up WordPress navigation menus (classic and block themes)
- Why a WordPress nav menu does not display, and how to fix it
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)