Если сайту или интернет-магазину требуется сложное меню, имеющее много уровней вложенности, часто используется метод хранения данных в базе данных который называется "Nested Sets". Как организовать подобную структуру и ее администрирование я писал в этой статье. А в данной - пример вывода меню с помощью стандартного виджета yii\widgets\Menu. Хоть для формирования меню могут использоваться разные решения, принцип одинаков.

Данному виджету необходимо передать массив элементов в определенном формате, например:
 echo \yii\widgets\Menu::widget([
      'items' => [
          ['label' => 'Home', 'url' => ['site/index']],
          ['label' => 'Products', 'url' => ['product/index'], 'items' => [
              ['label' => 'New Arrivals', 'url' => ['product/index', 'tag' => 'new']],
             ['label' => 'Most Popular', 'url' => ['product/index', 'tag' => 'popular']],
          ]],
          ['label' => 'Login', 'url' => ['site/login'], 'visible' => Yii::$app->user->isGuest],
      ],
 ]);

Для формирования массива из данных хранимых в БД по методу Nested Sets используем специальный класс NestedSetsTree, который можно найти на разных сайтах в слегка измененном виде. Я так же внес некоторые коррективы в его работу. Разместить класс можно в common/services/NestedSetsTree.php т.к. он может потребовать для меню и backend и frontend (да и не только для меню). Для фреймворка версии basic можно просто создать общий каталог «services» в корне проекта.
<?php

/*
 * Создание дерева элементов в виде массива
 */
namespace common\services;


class NestedSetsTree
{
    /**
     * @var string
     */
    public $leftAttribute = 'lft';

    /**
     * @var string
     */
    public $depthAttribute = 'depth';

    /**
     * @var string
     */
    public $labelAttribute = 'name';

    /**
     * @var string
     */
    public $childrenOutAttribute = 'children';



    /**
     * Построение дерева Nested Sets в виде массива
     *
     * @param array $collection Массив строк из БД
     * @return array
     */
    public function tree(array $collection)
    {

        $trees = []; // Дерево

        if (count($collection) > 0) {

            //Добавляем свои элементы
            foreach ($collection as &$col) {
                $col = $this->addItem($col);
            }

            // Узел. Используется для создания иерархии
            $stack = array();

            foreach ($collection as $node) {
                $item = $node;
                $item[$this->childrenOutAttribute] = array();

                // Количество элементов узла
                $l = count($stack);

                // Проверка имеем ли мы дело с разными уровнями
                while($l > 0 && $stack[$l - 1][$this->depthAttribute] >= $item[$this->depthAttribute]) {
                    array_pop($stack);
                    $l--;
                }

                // Если это корень
                if ($l == 0) {
                    // Создание корневого элемента
                    $i = count($trees);
                    $trees[$i] = $item;
                    $stack[] = &$trees[$i];
                } else {
                    // Добавление элемента в родительский
                    $i = count($stack[$l - 1][$this->childrenOutAttribute]);
                    $stack[$l - 1][$this->childrenOutAttribute][$i] = $item;
                    $stack[] = &$stack[$l - 1][$this->childrenOutAttribute][$i];
                }
            }
        }

        return $trees;
    }


    /**
     * Добавляет в массив дополнительные элементы
     * @param $node array Текущий элемент массива (строка из БД)
     * @return array
     */
    protected function addItem($node)
    {
        $newNode = [];
        return array_merge($node, $newNode);
    }
    
}



В результате работы метода tree получаем готовый массив, тем не менее его нужно слегка подкорректировать, т.к. у виджета yii\widgets\Menu есть свои требования.

Например элемент массива содержащий заголовок пункта меню должен называться «label», а у нас в БД, например «name». Дочерние пункты меню должны содержаться в элементе с ключем «items», тогда как данный класс по-умолчанию его называет «children».
Кроме того виджет предусматривает использование дополнительных полей, например «visible» - для включения/выключения вывода определенных элементов меню и «active» для назначения отдельного класса активному пункту меню.

Для корректировки создадим свой класс отнаследовав его от NestedSetsTree. Для примера я создам меню frontend, поэтому разместим в frontend/services/NestedSetsTreeMenu.php:
<?php

namespace frontend\services;

use common\services\NestedSetsTree;
use Yii;


class NestedSetsTreeMenu extends NestedSetsTree
{

    /**
     * @var string
     */
    public $childrenOutAttribute = 'items'; //children

    /**
     * @var string
     */
    public $labelOutAttribute = 'label'; //title


    /**
     * Добавляет в массив дополнительные элементы
     * @param $node
     * @return array
     */
    protected function addItem($node)
    {
        $node = $this->renameTitle($node); //переименование элемента массива
        $node = $this->visible($node); //видимость элементов меню
        $node = $this->makeActive($node); //выделение активного пункта меню

        return $node;
    }


    /**
     * Переименовываем элемент "name" в "label" (создаем label, удаляем name)
     * требуется для yii\widgets\Menu
     * @param $node
     * @return array
     */
    protected function renameTitle($node)
    {
        $newNode = [
            $this->labelOutAttribute => $node[$this->labelAttribute],
        ];
        unset($node[$this->labelAttribute]);

        return array_merge($node, $newNode);
    }


    /**
     * Видимость пункта меню (visible = false - скрыть элемент)
     * @param $node
     * @return array
     */
    protected function visible($node)
    {
        $newNode = [];

        //Гость
        if (Yii::$app->user->isGuest) {

            //Действие logout по-умолчанию проверяется на метод POST.
            //При использовании подкорректировать VerbFilter в контроллере (удалить это действие или добавить GET).
            if ($node['url'] === '/logout') {
                $newNode = [
                    'visible' => false,
                ];
            }

        //Авторизованный пользователь
        } else {
            if ($node['url'] === '/login' || $node['url'] === '/signup') {
                $newNode = [
                    'visible' => false,
                ];
            }
        }

        return array_merge($node, $newNode);
    }



    /**
     * Добавляет элемент "active" в массив с url соответствующим текущему запросу
     * для назначения отдельного класса активному пункту меню
     *
     * @param $node
     * @return array
     */
    private function makeActive($node)
    {
        //URL без хоста, слэша спереди и параметров запроса
        $path = Yii::$app->request->getPathInfo();

        //считается, что поле url в БД содержит слэш спереди, например "/about"
        if('/' . $path === $node['url']){
            $newNode = [
                'active' => true,
            ];
            return array_merge($node, $newNode);
        }

        return $node;
    }

}

Метод addItem, который переопределяется в данном классе, вызывается для каждого элемента дерева (для каждой строки из базы данных) и позволяет вносить любые изменения. В данном случае я:
  • переименовал поле children в items;
  • скрыл некоторые элементы меню в зависимости от статуса пользователя (для авторизованного скрыл пункты меню «вход» и «регистрация», а для гостя скрыл пункт «выход»). Касательно ссылки «/logout» еще будет ниже;
  • добавил в массив содержащий в поле url значение соответствующее url текущей страницы элемент 'active' со значением true, чтобы виджет присвоил активному пункту меню дополнительный стиль (для выделения его на странице с помощью CSS).

Для работы класса NestedSetsTree ему необходимо передать коллекцию – массив строк из БД. Таким образом необходимо создать вспомогательный класс, который будет формировать коллекцию и передавать ее уже непосредственно виджету.
Файл frontend/services/MenuArray.php:
<?php

namespace frontend\services;

use common\models\Menu;

class MenuArray
{

    static function getData()
    {

        $collection = Menu::find()->orderBy('lft')->asArray()->all();

        $menu = [];

        if($collection){
            $nsTree = new NestedSetsTreeMenu();
            $dataMenu = $nsTree->tree($collection); //создаем дерево в виде массива
            $menu = $dataMenu[0]['items']; //убираем корневой элемент
        }

        return $menu;
    }

}

Т.к.корневой элемент в меню не используется и создавался только для хранения данных в БД методом Nested Sets, тут мы его удаляем строкой
$menu = $dataMenu[0]['items'];

Осталось только вывести виджет в нужном месте, например где-то в frontend/views/layouts/main.php:
<?= \yii\widgets\Menu::widget([
    'items' => \frontend\services\MenuArray::getData(),
    'options' => ['id'=>'main-menu', 'class' => 'navbar'],
    'encodeLabels'=>false,
    'activateParents'=>true,
    'activeCssClass'=>'active',
]); ?>

Строкой
'items' => \frontend\services\MenuArray::getData()
передаем массив данных для меню и далее я указал некоторые опции, которые можно поменять или удалить. Подробнее про них можно почитать непосредственно в файле виджета vendor/yiisoft/yii2/widgets/Menu.php


Указание метода запроса для пункта меню.

Т.к. меню это ссылки с названиями элементов, обычно осуществляется GET запрос для перехода на нужную страницу, но иногда необходимо отправить запрос типа POST или другой.
В yii2 используется VerbFilter, который указывает каким типом запроса должен вызываться тот или иной метод. Так, например, в frontend/controllers/SiteController.php в методе behaviors можно увидить:
'verbs' => [
    'class' => VerbFilter::className(),
    'actions' => [
        'logout' => ['post'],
    ],
],

Т.е. при вызове действия 'logout' необходим POST запрос. Просто перейдя по ссылке «/logout» получим исключение «MethodNotAllowedHttpException».

Для отправки запроса типа POST, можно тегу <a> присвоить атрибут data-method="post", тогда yii2 создаст форму c POST запросом и не придется корректировать VerbFilter.
К сожалению yii\widgets\Menu не позволяет без изменения своего кода добавить данный атрибут, но мы можем наследовать его класс и немного изменить всего один метод.

Файл common/widgets/Menu.php:
<?php

namespace common\widgets;

use yii\helpers\ArrayHelper;
use yii\helpers\Html;
use yii\helpers\Url;
use \yii\widgets\Menu as MenuYii;

class Menu extends MenuYii
{
    protected function renderItem($item)
    {
        if (isset($item['url'])) {
            $template = ArrayHelper::getValue($item, 'template', $this->linkTemplate);

            return strtr($template, [
                '{url}' => Html::encode(Url::to($item['url'])),
                '{label}' => $item['label'],
                '{method}' => isset($item['method']) ? $item['method'] : 'get', //добавляем атрибут data-method
            ]);
        }

        $template = ArrayHelper::getValue($item, 'template', $this->labelTemplate);

        return strtr($template, [
            '{label}' => $item['label'],
        ]);
    }
}

Тут я добавил всего одну строку
'{method}' => isset($item['method']) ? $item['method'] : 'get',
Теперь когда код виджета будет создавать ссылку, он заменит {method} на значение указанное в элементе массива «method».

Добавим этот элемент в массив, для этого создадим еще один метод в классе NestedSetsTreeMenu:

  • подключаем его в addItem:
protected function addItem($node)
{

    $node = $this->establishMethod($node);
    $node = $this->renameTitle($node); //переименование элемента массива
    $node = $this->visible($node); //видимость элементов меню
    $node = $this->makeActive($node); //выделение активного пункта меню

    return $node;
}

  • добавляем ниже внутри класса:
/**
 * Добавление элемента method со значением post для формирования атрибута data-method="post"
 * @param $node
 * @return array
 */
protected function establishMethod($node)
{
    if($node['url'] === '/logout'){
        $newNode = [
            'method' => 'post',
        ];
        return array_merge($node, $newNode);
    }

    return $node;
}

Итак, в массив у которого в поле url будет «/logout» добавится новый элемент 'method' со значением 'post'.

Осталось подключить свой класс виджета, который был переопределен:
<?= \common\widgets\Menu::widget([
    'items' => \frontend\services\MenuArray::getData(),
    'options' => ['id'=>'main-menu', 'class' => 'navbar'],
    'encodeLabels'=>false,
    'activateParents'=>true,
    'activeCssClass'=>'active',
    //добавление атрибута data-method
    'linkTemplate' => '<a href="{url}" data-method="{method}">{label}</a>',
]); ?>

Тут я указал новое пространство имен класса Menu и дописал свойство «linkTemplate» виджета, которое определяет как должна выглядеть ссылка, добавил в нее атрибут «data-method».
Итак, в зависимости от структуры вашего меню, вывод виджета без оформления стилей CSS может выглядеть, например, так: