6.x menu.inc menu_link_save(&$item)
7.x menu.inc menu_link_save(&$item, $existing_item = array(), $parent_candidates = array())

Saves a menu link.

After calling this function, rebuild the menu cache using menu_cache_clear_all().


$item: An associative array representing a menu link item, with elements:

  • link_path: (required) The path of the menu item, which should be normalized first by calling drupal_get_normal_path() on it.
  • link_title: (required) Title to appear in menu for the link.
  • menu_name: (optional) The machine name of the menu for the link. Defaults to 'navigation'.
  • weight: (optional) Integer to determine position in menu. Default is 0.
  • expanded: (optional) Boolean that determines if the item is expanded.
  • options: (optional) An array of options, see l() for more.
  • mlid: (optional) Menu link identifier, the primary integer key for each menu link. Can be set to an existing value, or to 0 or NULL to insert a new link.
  • plid: (optional) The mlid of the parent.
  • router_path: (optional) The path of the relevant router item.

$existing_item: Optional, the current record from the {menu_links} table as an array.

$parent_candidates: Optional array of menu links keyed by mlid. Used by _menu_navigation_links_rebuild() only.

Return value

The mlid of the saved menu link, or FALSE if the menu link could not be saved.

Related topics

18 calls to menu_link_save()
book_admin_edit_submit in modules/book/book.admin.inc
Form submission handler for book_admin_edit().
MenuLinksUnitTestCase::assertMenuLinkParents in modules/simpletest/tests/menu.test
Assert that at set of links is properly parented.
MenuLinksUnitTestCase::createLinkHierarchy in modules/simpletest/tests/menu.test
Create a simple hierarchy of links.
MenuLinksUnitTestCase::testMenuLinkReparenting in modules/simpletest/tests/menu.test
Test automatic reparenting of menu links.
MenuLinksUnitTestCase::testMenuLinkRouterReparenting in modules/simpletest/tests/menu.test
Test automatic reparenting of menu links derived from menu routers.

... See full list


includes/menu.inc, line 3135
API for the Drupal menu system.


function menu_link_save(&$item, $existing_item = array(), $parent_candidates = array()) {
  drupal_alter('menu_link', $item);

  // This is the easiest way to handle the unique internal path '<front>',
  // since a path marked as external does not need to match a router path.
  $item['external'] = url_is_external($item['link_path']) || $item['link_path'] == '<front>' ? 1 : 0;

  // Load defaults.
  $item += array(
    'menu_name' => 'navigation',
    'weight' => 0,
    'link_title' => '',
    'hidden' => 0,
    'has_children' => 0,
    'expanded' => 0,
    'options' => array(),
    'module' => 'menu',
    'customized' => 0,
    'updated' => 0,
  if (isset($item['mlid'])) {
    if (!$existing_item) {
      $existing_item = db_query('SELECT * FROM {menu_links} WHERE mlid = :mlid', array(
        'mlid' => $item['mlid'],
    if ($existing_item) {
      $existing_item['options'] = unserialize($existing_item['options']);
  else {
    $existing_item = FALSE;

  // Try to find a parent link. If found, assign it and derive its menu.
  $parent = _menu_link_find_parent($item, $parent_candidates);
  if (!empty($parent['mlid'])) {
    $item['plid'] = $parent['mlid'];
    $item['menu_name'] = $parent['menu_name'];
  else {
    $item['plid'] = 0;
  $menu_name = $item['menu_name'];
  if (!$existing_item) {
    $item['mlid'] = db_insert('menu_links')
      'menu_name' => $item['menu_name'],
      'plid' => $item['plid'],
      'link_path' => $item['link_path'],
      'hidden' => $item['hidden'],
      'external' => $item['external'],
      'has_children' => $item['has_children'],
      'expanded' => $item['expanded'],
      'weight' => $item['weight'],
      'module' => $item['module'],
      'link_title' => $item['link_title'],
      'options' => serialize($item['options']),
      'customized' => $item['customized'],
      'updated' => $item['updated'],

  // Directly fill parents for top-level links.
  if ($item['plid'] == 0) {
    $item['p1'] = $item['mlid'];
    for ($i = 2; $i <= MENU_MAX_DEPTH; $i++) {
      $item["p{$i}"] = 0;
    $item['depth'] = 1;
  else {
    if ($item['has_children'] && $existing_item) {
      $limit = MENU_MAX_DEPTH - menu_link_children_relative_depth($existing_item) - 1;
    else {
      $limit = MENU_MAX_DEPTH - 1;
    if ($parent['depth'] > $limit) {
      return FALSE;
    $item['depth'] = $parent['depth'] + 1;
    _menu_link_parents_set($item, $parent);

  // Need to check both plid and menu_name, since plid can be 0 in any menu.
  if ($existing_item && ($item['plid'] != $existing_item['plid'] || $menu_name != $existing_item['menu_name'])) {
    _menu_link_move_children($item, $existing_item);

  // Find the router_path.
  if (empty($item['router_path']) || !$existing_item || $existing_item['link_path'] != $item['link_path']) {
    if ($item['external']) {
      $item['router_path'] = '';
    else {

      // Find the router path which will serve this path.
      $item['parts'] = explode('/', $item['link_path'], MENU_MAX_PARTS);
      $item['router_path'] = _menu_find_router_path($item['link_path']);

  // If every value in $existing_item is the same in the $item, there is no
  // reason to run the update queries or clear the caches. We use
  // array_intersect_key() with the $item as the first parameter because
  // $item may have additional keys left over from building a router entry.
  // The intersect removes the extra keys, allowing a meaningful comparison.
  if (!$existing_item || array_intersect_key($item, $existing_item) != $existing_item) {
      'menu_name' => $item['menu_name'],
      'plid' => $item['plid'],
      'link_path' => $item['link_path'],
      'router_path' => $item['router_path'],
      'hidden' => $item['hidden'],
      'external' => $item['external'],
      'has_children' => $item['has_children'],
      'expanded' => $item['expanded'],
      'weight' => $item['weight'],
      'depth' => $item['depth'],
      'p1' => $item['p1'],
      'p2' => $item['p2'],
      'p3' => $item['p3'],
      'p4' => $item['p4'],
      'p5' => $item['p5'],
      'p6' => $item['p6'],
      'p7' => $item['p7'],
      'p8' => $item['p8'],
      'p9' => $item['p9'],
      'module' => $item['module'],
      'link_title' => $item['link_title'],
      'options' => serialize($item['options']),
      'customized' => $item['customized'],
      ->condition('mlid', $item['mlid'])

    // Check the has_children status of the parent.
    if ($existing_item && $menu_name != $existing_item['menu_name']) {

    // Notify modules we have acted on a menu item.
    $hook = 'menu_link_insert';
    if ($existing_item) {
      $hook = 'menu_link_update';
    module_invoke_all($hook, $item);

    // Now clear the cache.
  return $item['mlid'];


Poetro’s picture

In case you want to save internal links with this function (like links to nodes) in your custom module, and not through the menu admin interface, be sure to validate the item with menu_edit_item_validate() first like:

      $form_state = array(
        'values' => array(
          'menu_name'  => $menu_name,
          'weight'     => $weight,
          'link_title' => $link_title,
          'link_path'  => $link_path,
          'module'     => 'mymodule',
          'mlid'       => 0,
      // Validate and transform the item, so it conforms with Drupal standards.
      menu_edit_item_validate(array(), $form_state);
      // Save the item to database.

If it is not valid it may not be usable or may not load right. Also make sure to rebuild the menu with menu_rebuild() after you did all the manipulations, as Drupal caches it pretty heavy.

chx’s picture

You need to call drupal_get_normal_path() on the link path but not the validate. You need to call menu_cache_clear_all() but not menu_rebuild().

Mac_Weber’s picture

In order to add a translatable item the $item array must contain 'language' and 'customized' => 1. Moreover, differently than node_save(), the menu_link_save() does not play well with path aliases!!!

$menu_item = array(
  'link_title' => 'The menu link title',
  'menu_name'  => 'machine-name-of-the-menu', // It uses '-', not '_'
  'customized' => 1, // Must be 1 in order to not dispaly the link in any language
  'link_path'  => 'node/' . $node->nid, // menu_link_save() does not like aliases. Then 'nid' is used
  'language'  => 'en', // language is required in order to make the item translatable
$mlid = menu_link_save($menu_item); // You don't need to receive it in a variable
$ml = menu_link_load($mlid); // I'm loading $mlid just to display the result and verify it
print_r($ml); // Use print_r() only if you are testing on the block "Execute PHP"
Mac_Weber’s picture

sorry about the code formatting, but I could not get it to look nice using the allowed tags.

almc’s picture

In D7 this function doesn't seem to work properly in the hook_install() or hook_enable() - not adding menu links, although this function works ok after the module is enabled. There seem to have been a similar issue reported for D6 - https://drupal.org/node/1822288. Not sure if it's a feature or bug.
Also, the link doesn't get added (at least at first level) if its link/router_path is not registered in menu_hook() as well.

spydmobile’s picture

I have it working in module.install but I still cant presave items into the menu if they are not already registered by the hook_menu :( Has anyone found away around this?

24ma13wg’s picture

I can't get this to work from hook_install, but why does it appear to work in profiles/standard/standard.info during an install of Drupal?

alexluke’s picture

Calling menu_rebuild() in your hook_install() function before trying to create a menu item seems to do the trick.

chrisolof’s picture

I just discovered how to get this working in hook_install():

Add to your menu link array

'customized' => 1,

So, for example, to make a menu link that points at a view that defines the path "services", this works:

$item = array(
    'menu_name' => 'main-menu',
    'link_path' => 'services',
    'router_path' => 'services',
    'link_title' => 'Services',
    'weight' => 0,
    'customized' => 1,
  $mlid = menu_link_save($item);
yusufhm’s picture

Adding 'customized' => 1, really did it. Calling menu_rebuild() did not. Thanks for this tip!

geraldvillorente’s picture

Thank you for saving my ass. I was spending almost 5 hours trying to make my script to work but it does nothing. Adding "$link['customized'] = 1;" did the trick.

My script below in profile .install.

  // Get "Add content" mild.
  $mlid = db_query("SELECT mlid FROM {menu_links} WHERE link_title=:link_title AND  link_path=:link_path AND menu_name=:menu_name",
      ':link_title' => 'Add content',
      ':link_path'  => 'node/add',
      ':menu_name'  => 'navigation',
  $link = menu_link_load($mlid);
  // Alter "expanded" value.
  $link['expanded'] = 1;
  $link['customized'] = 1;
chandraraj’s picture

I was trying to create menu links for a page programmatically. It worked well if a menu link was a node page. But menu cache had to be cleared if a menu link was a custom page though the custom pages were created using hook_menu. The issue is fixed after adding 'customized' => 1, into menu item array. Thanks a lot for your valuable suggestion.

BeauTownsend’s picture

Used menu_save and menu_link_save successfully in hook_install(). Customized was the key!

asparaguscat’s picture

How would one set the description using menu_link_save()?
I'm creating a new menu item this way, but it's showing with the default description:
"Config description"
I've tried setting 'description' as it used to be in D6, but that appears to have no effect.
I've cleared menu caches.

Tor Arne Thune’s picture

$item = array(
  'link_title' => 'Links',
  'link_path' => 'links',
  'menu_name' => 'main-menu',
  'weight' => 2,
$item['options']['attributes']['title'] = 'My links';
GuyPaddock’s picture

It's under $item['options']['attributes']['title'].