PHP / MySQL build tree menu

I am trying to build an un-oredered list menu tree from my database in PHP and MySQL.

I have an array of page objects I am returning from the db. Each page object has parent_id attribute, which is set to null if it doesn't have a parent. Here's what the page objects look like:

page object
  id
  title
  parent_id

If possible I would like to not do it recursively and only hit the database once, since I am going to be building the menu on almost every request. I want to create a function that I can just pass my array of objects to and it will return the html list.


I like @mario's solution, and have improved on it with the prevention of the excess <ul>. I would just recommend doing an ORDER BY on your SQL query to get the menu in the order you want (might even recommend a weight/sequence column be added to the schema.

Data setup:

$menu = array( // Presumed to have been coming from a SQL SELECT, populated for demo.
  array('id'=>1,'title'=>'Menu 1',          'parent_id'=>null),
  array('id'=>2,'title'=>'Sub 1.1',         'parent_id'=>1),
  array('id'=>3,'title'=>'Sub 1.2',         'parent_id'=>1),
  array('id'=>4,'title'=>'Sub 1.3',         'parent_id'=>1),
  array('id'=>5,'title'=>'Menu 2',          'parent_id'=>null),
  array('id'=>6,'title'=>'Sub 2.1',         'parent_id'=>5),
  array('id'=>7,'title'=>'Sub Sub 2.1.1',   'parent_id'=>6),
  array('id'=>8,'title'=>'Sub 2.2',         'parent_id'=>5),
  array('id'=>9,'title'=>'Menu 3',          'parent_id'=>null),
);

Handling:

function has_children($rows,$id) {
  foreach ($rows as $row) {
    if ($row['parent_id'] == $id)
      return true;
  }
  return false;
}
function build_menu($rows,$parent=0)
{  
  $result = "<ul>";
  foreach ($rows as $row)
  {
    if ($row['parent_id'] == $parent){
      $result.= "<li>{$row['title']}";
      if (has_children($rows,$row['id']))
        $result.= build_menu($rows,$row['id']);
      $result.= "</li>";
    }
  }
  $result.= "</ul>";

  return $result;
}
echo build_menu($menu);

Output:

<ul>
  <li>Menu 1<ul>
    <li>Sub 1.1</li>
    <li>Sub 1.2</li>
    <li>Sub 1.3</li>
  </ul></li>
  <li>Menu 2<ul>
    <li>Sub 2.1<ul>
      <li>Sub Sub 2.1.1</li>
    </ul></li>
    <li>Sub 2.2</li>
  </ul></li>
  <li>Menu 3</li>
</ul>

Instead of querying the database recursively, you can just pull out all entries and make the output function recursive. It's often as trivial as:

function print_list($array, $parent=0) {
    print "<ul>";
    foreach ($array as $row) {
        if ($row->parent_id == $parent) {
            print "<li>$row->title";
            print_list($array, $row->id);  # recurse
            print "</li>";
    }   }
    print "</ul>";
}

It's only important to nest <ul>s into <li>. Or just use HTML and leave out the closing </li>.

Actually this prints too many <ul>s, so I would check for existence of sublevels and avoid printing it directly.


I ended up going with this solution (pastebin reference):

<?php

/**
 * Generate HTML for multi-dimensional menu from MySQL database
 * with ONE QUERY and WITHOUT RECURSION 
 * @author J. Bruni
 */
class MenuBuilder
{
    /**
     * MySQL connection
     */
    var $conn;

    /**
     * Menu items
     */
    var $items = array();

    /**
     * HTML contents
     */
    var $html  = array();

    /**
     * Create MySQL connection
     */
    function MenuBuilder()
    {
        $this->conn = mysql_connect( 'localhost', 'user', 'pass' );
        mysql_select_db( 'example', $this->conn );
    }

    /**
     * Perform MySQL query and return all results
     */
    function fetch_assoc_all( $sql )
    {
        $result = mysql_query( $sql, $this->conn );

        if ( !$result )
            return false;

        $assoc_all = array();

        while( $fetch = mysql_fetch_assoc( $result ) )
            $assoc_all[] = $fetch;

        mysql_free_result( $result );

        return $assoc_all;
    }

    /**
     * Get all menu items from database
     */
    function get_menu_items()
    {
        // Change the field names and the table name in the query below to match tour needs
        $sql = 'SELECT id, parent_id, title, link, position FROM menu_item ORDER BY parent_id, position;';
        return $this->fetch_assoc_all( $sql );
    }

    /**
     * Build the HTML for the menu 
     */
    function get_menu_html( $root_id = 0 )
    {
        $this->html  = array();
        $this->items = $this->get_menu_items();

        foreach ( $this->items as $item )
            $children[$item['parent_id']][] = $item;

        // loop will be false if the root has no children (i.e., an empty menu!)
        $loop = !empty( $children[$root_id] );

        // initializing $parent as the root
        $parent = $root_id;
        $parent_stack = array();

        // HTML wrapper for the menu (open)
        $this->html[] = '<ul>';

        while ( $loop && ( ( $option = each( $children[$parent] ) ) || ( $parent > $root_id ) ) )
        {
            if ( $option === false )
            {
                $parent = array_pop( $parent_stack );

                // HTML for menu item containing childrens (close)
                $this->html[] = str_repeat( "\t", ( count( $parent_stack ) + 1 ) * 2 ) . '</ul>';
                $this->html[] = str_repeat( "\t", ( count( $parent_stack ) + 1 ) * 2 - 1 ) . '</li>';
            }
            elseif ( !empty( $children[$option['value']['id']] ) )
            {
                $tab = str_repeat( "\t", ( count( $parent_stack ) + 1 ) * 2 - 1 );

                // HTML for menu item containing childrens (open)
                $this->html[] = sprintf(
                    '%1$s<li><a href="%2$s">%3$s</a>',
                    $tab,   // %1$s = tabulation
                    $option['value']['link'],   // %2$s = link (URL)
                    $option['value']['title']   // %3$s = title
                ); 
                $this->html[] = $tab . "\t" . '<ul class="submenu">';

                array_push( $parent_stack, $option['value']['parent_id'] );
                $parent = $option['value']['id'];
            }
            else
                // HTML for menu item with no children (aka "leaf") 
                $this->html[] = sprintf(
                    '%1$s<li><a href="%2$s">%3$s</a></li>',
                    str_repeat( "\t", ( count( $parent_stack ) + 1 ) * 2 - 1 ),   // %1$s = tabulation
                    $option['value']['link'],   // %2$s = link (URL)
                    $option['value']['title']   // %3$s = title
                );
        }

        // HTML wrapper for the menu (close)
        $this->html[] = '</ul>';

        return implode( "\r\n", $this->html );
    }
}

Example Data:

CREATE TABLE `menu_item` (
  `id` int(11) NOT NULL,
  `title` varchar(75) DEFAULT NULL,
  `link` varchar(100) DEFAULT NULL,
  `parent_id` int(11) DEFAULT NULL,
  `position` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
);

INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (1,'1','1.html',0,1);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (2,'2','2.html',0,2);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (3,'11','11.html',1,1);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (4,'12','12.html',1,2);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (5,'21','21.html',2,1);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (6,'22','22.html',2,2);
INSERT INTO `menu_item` (`id`, `title`, `link`, `parent_id`, `position`) VALUES (7,'3','3.html',0,3);

Usage:

$menu = new MenuBuilder();
echo '<pre>' . htmlentities( $menu->get_menu_html() ) . '</pŕe>';