Хабрахабр

[Из песочницы] Мультиязычные деревья в Yii2 на примере создания модуля меню

Вступление

Задача довольно простая, но не совсем очевидная в рамках данного фреймворка. Многие начинающие веб-разработчики сталкиваются с необходимостью создания меню, каталогов или рубрикаторов для своего проекта на Yii2, которые бы имели иерархическую структуру, но при этом поддерживали мультиязычность. Причём речь тут идёт не о переводе интерфейса штатными средствами фреймворка, а про хранение данных в базе на нескольких языках. Есть большое количество готовых расширений для создания древовидных структур (меню, каталогов итд.), но довольно сложно найти решение, которое бы поддерживало полноценную работу с несколькими языками. Также достаточно сложно найти удобный и полностью работоспособный виджет для управления деревом, который мог бы также работать с многоязычным контентом без сложных манипуляций с кодом.

Для примера я буду использовать шаблон приложения Yii2 App Basic, но вы можете адаптировать всё под свой шаблон, если он отличается от базового. Я хотел бы поделиться рецептом того, как можно создавать подобные модули на примере реализации модуля меню.

Подготовка

Для реализации задачи нам понадобится несколько замечательных расширений, а именно:

  • Adjacency List — для хранения древовидной структуры
    меню в БД;
  • Yii2 Bootstrap Treeview — виджет
    для удобного отображения меню в виде дерева;
  • Translateable Behavior — поведение для
    поддержки мультиязычности в моделях;

Устанавливаем данные расширения через composer:

composer require paulzi/yii2-adjacency-list
composer require execut/yii2-widget-bootstraptreeview
composer require creocoder/yii2-translateable

Для реализации меню в виде модуля, с помощью Gii генератора (либо вручную) создаём новый модуль menu и подключаем его в настройках приложения.

Я предпочитаю использовать вот это расширение для Yii2. В проекте также должен быть настроен механизм переключения языков.

Создание моделей

На самом деле, для хранения мультиязычных данных могут использоваться разные методики, но вариант с двумя таблицами, одна из которых хранит саму сущность, а вторая — её языковые вариации, мне нравится больше остальных. Для хранения меню (либо другой сущности, которая имеет мультиязычность) в базе данных нам необходимо создать две таблицы. Вот пример такой миграции: Для создания таблиц удобно использовать миграции.

m180819_083502_menu_init.php

<?php use yii\db\Schema;
use yii\db\Migration; class m180819_083502_menu_init extends Migration
public function safeUp() { $tableOptions = 'ENGINE=InnoDB'; $this->createTable('{{%menu}}', [ 'id'=> $this->primaryKey(11), 'parent_id'=> $this->integer(11)->null()->defaultValue(null), 'link'=> $this->string(255)->notNull()->defaultValue('#'), 'link_attributes'=> $this->text()->notNull(), 'icon_class'=> $this->string(255)->notNull(), 'sort'=> $this->integer(11)->notNull()->defaultValue(0), 'status'=> $this->tinyInteger(1)->notNull()->defaultValue(1), ], $tableOptions); $this->createIndex('parent_sort', '{{%menu}}', ['parent_id','sort'], false); $this->createTable('{{%menu_lang}}', [ 'owner_id'=> $this->integer(11)->notNull(), 'language'=> $this->string(2)->notNull(), 'name'=> $this->string(255)->notNull(), 'title'=> $this->text()->notNull(), ], $tableOptions); $this->addPrimaryKey('pk_on_menu_lang', '{{%menu_lang}}', ['owner_id','language']); $this->addForeignKey( 'fk_menu_lang_owner_id', '{{%menu_lang}}', 'owner_id', '{{%menu}}', 'id', 'CASCADE', 'CASCADE' ); // Insert sample data $this->batchInsert( '{{%menu}}', ['id', 'parent_id', 'link', 'link_attributes', 'icon_class', 'sort', 'status'], [ [ 'id' => '1', 'parent_id' => null, 'link' => '#', 'link_attributes' => '', 'icon_class' => '', 'sort' => '0', 'status' => '0', ], [ 'id' => '2', 'parent_id' => '1', 'link' => '/', 'link_attributes' => '', 'icon_class' => 'fa fa-home', 'sort' => '0', 'status' => '1', ], ] ); $this->batchInsert( '{{%menu_lang}}', ['owner_id', 'language', 'name', 'title'], [ [ 'owner_id' => '1', 'language' => 'ru', 'name' => 'Главное меню', 'title' => '', ], [ 'owner_id' => '1', 'language' => 'en', 'name' => 'Main menu', 'title' => '', ], [ 'owner_id' => '2', 'language' => 'ru', 'name' => 'Главная', 'title' => 'Главная страница сайта', ], [ 'owner_id' => '2', 'language' => 'en', 'name' => 'Home', 'title' => 'Site homepage', ], ] ); } public function safeDown() { $this->truncateTable('{{%menu}} CASCADE'); $this->dropForeignKey('fk_menu_lang_owner_id', '{{%menu_lang}}'); $this->dropTable('{{%menu}}'); $this->dropPrimaryKey('pk_on_menu_lang', '{{%menu_lang}}'); $this->dropTable('{{%menu_lang}}'); }
}

Поместим данный файл миграции в папку /migrations нашего проекта, и
выполним в консоли команду:

php yii migrate

Так как в проекте мультиязычность и деревья могут встречаться не только в меню, но и в других сущностях (например, страницы сайта), я предлагаю вынести методы, которые реализуют механизм мультиязычности и организацию дерева, в отдельные трейты, чтобы в дальнейшем мы могли легко использовать их в других моделях без дублирования кода. После того, как мы создали необходимые таблицы и добавили в них новое меню с помощью миграции, нам нужно создать модели. Создадим в корне приложения папочку traits (если её там ещё нет) и поместим туда два файла:

LangTrait.php

<?php namespace app\traits; use Yii;
use yii\behaviors\SluggableBehavior;
use creocoder\translateable\TranslateableBehavior; trait LangTrait
{ public static function langClass() { return self::class . 'Lang'; } public static function langTableName() { return self::tableName() . '_lang'; } public function langBehaviors($translationAttributes) { return [ 'translateable' => [ 'class' => TranslateableBehavior::class, 'translationAttributes' => $translationAttributes, 'translationRelation' => 'translations', 'translationLanguageAttribute' => 'language', ], ]; } public function transactions() { return [ self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE, ]; } public function getLang() { return $this->hasOne(self::langClass(), ['owner_id' => 'id'])->where([self::langTableName() . '.language' => Yii::$app->language]); } public function getTranslations() { return $this->hasMany(self::langClass(), ['owner_id' => 'id']); } }

TreeTrait.php

<?php namespace app\traits; use Yii;
use yii\helpers\Html;
use paulzi\adjacencyList\AdjacencyListBehavior; trait TreeTrait
{ private static function getQueryClass() { return self::class . 'Query'; } public function treeBehaviors() { return [ 'tree' => [ 'class' => AdjacencyListBehavior::class, 'parentAttribute' => 'parent_id', 'sortable' => [ 'step' => 10, ], 'checkLoop' => false, 'parentsJoinLevels' => 5, 'childrenJoinLevels' => 5, ], ]; } public static function find() { $queryClass = self::getQueryClass(); return new $queryClass(get_called_class()); } public static function listTree($node = null, $level = 1, $nameAttribute = 'name', $prefix = '-->') { $result = []; if (!$node) { $node = self::find()->roots()->one()->populateTree(); } if ($node->isRoot()) { $result[$node['id']] = mb_strtoupper($node[$nameAttribute ?: 'slug']); } if ($node['children']) { foreach ($node['children'] as $child) { $result[$child['id']] = str_repeat($prefix, $level) . $child[$nameAttribute]; $result = $result + self::listTree($child, $level + 1, $nameAttribute); } } return $result; } public static function treeViewData($node = null) { if ($node === null) { $node = self::find()->roots()->one()->populateTree(); } $result = null; $items = null; $children = null; if ($node['children']) { foreach ($node['children'] as $child) { $items[] = self::treeViewData($child); } $children = call_user_func_array('array_merge', $items); } $result[] = [ 'text' => Html::a($node['lang']['name'] ?: $node['id'], ['update', 'id' => $node['id']], ['title' => Yii::t('app', 'Редактировать элемент')]), 'tags' => [ Html::a( '<i class="glyphicon glyphicon-arrow-down"></i>', ['move-down', 'id' => $node['id']], ['title' => Yii::t('app', 'Передвинуть вниз')] ), Html::a( '<i class="glyphicon glyphicon-arrow-up"></i>', ['move-up', 'id' => $node['id']], ['title' => Yii::t('app', 'Передвинуть вверх')] ) ], 'backColor' => $node['status'] == 0 ? '#ccc' : '#fff', 'selectable' => false, 'nodes' => $children, ]; return $result; }
}

Модели помещаем в /modules/menu/models: Теперь создадим непосредственно сами модели для работы с меню, в которых подключим трейты для дерева и мультиязычности.

Menu.php

<?php namespace app\modules\menu\models; use Yii; class Menu extends \yii\db\ActiveRecord
{ use \app\traits\TreeTrait; use \app\traits\LangTrait; const STATUS_ACTIVE = 1; const STATUS_INACTIVE = 0; public function behaviors() { $behaviors = []; return array_merge( $behaviors, $this->treeBehaviors(), $this->langBehaviors(['name', 'title']) ); } public static function tableName() { return 'menu'; } public function rules() { return [ [['parent_id', 'sort', 'status'], 'integer'], [['link', 'icon_class'], 'string', 'max' => 255], [['link_attributes'], 'string'], [['link'], 'default', 'value' => '#'], [['link_attributes', 'icon_class'], 'default', 'value' => ''], [['parent_id'], 'exist', 'skipOnError' => true, 'targetClass' => self::class, 'targetAttribute' => ['parent_id' => 'id']], ]; } public function attributeLabels() { return [ 'id' => Yii::t('app', 'ID'), 'parent_id' => Yii::t('app', 'Родитель'), 'link' => Yii::t('app', 'Ссылка'), 'link_attributes' => Yii::t('app', 'Атрибуты ссылки (JSON массив)'), 'icon_class' => Yii::t('app', 'Класс иконки'), 'sort' => Yii::t('app', 'Сортировка'), 'status' => Yii::t('app', 'Опубликован'), ]; } public static function menuItems($node = null) { if ($node === null) { $node = self::find()->roots()->one()->populateTree(); } $result = null; $items = null; $children = null; if ($node['children']) { foreach ($node['children'] as $child) { $items[] = self::menuItems($child); } $children = call_user_func_array('array_merge', $items); } $result[] = [ 'label' => ($node['icon_class'] ? '<i class="' . $node['icon_class'] . '"></i> ' . ($node['lang']['name'] ?: $node['id']) : ($node['lang']['name'] ?: $node['id'] )), 'encode' => ($node['icon_class'] ? false : true), 'url' => [$node['link'], 'language' => Yii::$app->language], 'active' => $node['link'] == Yii::$app->request->url ? true : false, 'linkOptions' => ($node['link_attributes'] ? array_merge(json_decode($node['link_attributes'], true), ['title' => ($node['lang']['title'] ?: $node['lang']['name'])]) : ['title' => ($node['lang']['title'] ?: $node['lang']['name'])]), 'items' => $children, ]; return $result; }
}

MenuLang.php

<?php namespace app\modules\menu\models; use Yii; class MenuLang extends \yii\db\ActiveRecord
{ public static function tableName() { return 'menu_lang'; } public function rules() { return [ [['name'], 'required'], [['name', 'title'], 'string', 'max' => 255], ]; } public function attributeLabels() { return [ 'owner_id' => Yii::t('app', 'Владелец'), 'language' => Yii::t('app', 'Язык'), 'name' => Yii::t('app', 'Название'), 'title' => Yii::t('app', 'Всплывающая подсказка'), ]; } public function getOwner() { return $this->hasOne(Menu::class, ['id' => 'owner_id']); }
}

MenuQuery.php

<?php namespace app\modules\menu\models; use paulzi\adjacencyList\AdjacencyListQueryTrait; class MenuQuery extends \yii\db\ActiveQuery
{ use AdjacencyListQueryTrait;
}

MenuSearch.php

<?php namespace app\modules\menu\models; use Yii;
use yii\base\Model;
use yii\data\ActiveDataProvider;
use app\modules\menu\models\Menu; class MenuSearch extends Menu
{ public $name; public function rules() { return [ [['id', 'parent_id', 'sort', 'status'], 'integer'], [['link', 'link_attributes', 'icon_class'], 'safe'], [['name'], 'safe'], ]; } public function scenarios() { return Model::scenarios(); } public function search($params) { $query = parent::find()->joinWith(['lang']); $dataProvider = new ActiveDataProvider([ 'query' => $query, 'sort' => ['defaultOrder' => ['sort' => SORT_ASC]] ]); $dataProvider->sort->attributes['name'] = [ 'asc' => [ 'menu_lang.name' => SORT_ASC, ], 'desc' => [ 'menu_lang.name' => SORT_DESC, ], ]; $this->load($params); if (!$this->validate()) { return $dataProvider; } $query->andFilterWhere([ 'id' => $this->id, 'parent_id' => $this->parent_id, 'sort' => $this->sort, 'status' => $this->status, ]); $query->andFilterWhere(['like', 'link', $this->link]); $query->andFilterWhere(['like', 'link_attributes', $this->link_attributes]); $query->andFilterWhere(['like', 'icon_class', $this->icon_class]); $query->andFilterWhere(['like', 'name', $this->name]); return $dataProvider; }
}

Создание контроллеров

Чтобы упростить себе жизнь в будущем, мы создадим один базовый контроллер, в котором будут все необходимые действия, а для разных сущностей, будь то меню, или каталог, или страницы — будем наследоваться от него. Для осуществления CRUD операций над мультиязычными деревьями нам нужен контроллер.

Создадим файл /base/controllers/AdminLangTreeController.php. Классы нашего проекта, которые мы будем использовать как базовые, мы разместим в папке /base. Этот контроллер у нас будет базовым для CRUD всех сущностей, в которых реализовано дерево и мультиязычность:

AdminLangTreeController.php

<?php namespace app\base\controllers; use Yii;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;
use yii\helpers\Url; class AdminLangTreeController extends Controller
{ public $modelClass; public $modelClassSearch; public $modelName; public $modelNameLang; public function behaviors() { return [ 'verbs' => [ 'class' => VerbFilter::class, 'actions' => [ 'delete' => ['POST'], ], ], ]; } public function actionIndex() { // Если корневой элемент дерева отсутствует, он будет создан автоматически if (count($this->modelClass::find()->roots()->all()) == 0) { $model = new $this->modelClass; $model->makeRoot()->save(); Yii::$app->session->setFlash('info', Yii::t('app', 'Корневой элемент создан автоматически')); return $this->redirect(['index']); } $searchModel = new $this->modelClassSearch; $dataProvider = $searchModel->search(Yii::$app->request->queryParams); $dataProvider->pagination = false; return $this->render('index', [ 'searchModel' => $searchModel, 'dataProvider' => $dataProvider, ]); } public function actionCreate() { // Проверка наличия корневого элемента if (count($this->modelClass::find()->roots()->all()) == 0) { return $this->redirect(['index']); } // Создание новой записи и привязка её к дереву $model = new $this->modelClass; $root = $model::find()->roots()->one(); $model->parent_id = $root->id; // Загрузка моделей из формы if ($model->load(Yii::$app->request->post()) && $model->validate()) { $parent = $model::findOne($model->parent_id); $model->appendTo($parent)->save(); // Сохраняем мультиязычные данные foreach (Yii::$app->request->post($this->modelNameLang, []) as $language => $data) { foreach ($data as $attribute => $translation) { $model->translate($language)->$attribute = $translation; } } $model->save(); Yii::$app->session->setFlash('success', Yii::t('app', 'Создание прошло успешно')); return $this->redirect(['update', 'id' => $model->id]); } else { return $this->render('create', [ 'model' => $model, ]); } } public function actionUpdate($id) { // Находим нужную модель $model = $this->modelClass::find()->with('translations')->where(['id' => $id])->one(); if ($model === null) { throw new NotFoundHttpException(Yii::t('app', 'Страница не найдена')); } // Загрузка данных из формы if ($model->load(Yii::$app->request->post()) && $model->save()) { foreach (Yii::$app->request->post($this->modelNameLang, []) as $language => $data) { foreach ($data as $attribute => $translation) { $model->translate($language)->$attribute = $translation; } } $model->save(); Yii::$app->session->setFlash('success', Yii::t('app', 'Обновление произведено успешно')); if (Yii::$app->request->post('save') !== null) { return $this->redirect(['index']); } return $this->redirect(['update', 'id' => $model->id]); } else { return $this->render('update', [ 'model' => $model, ]); } } public function actionDelete($id) { $model = $this->findModel($id); // Запрещаем удаление узла, если у него есть потомки if (count($model->children) > 0) { Yii::$app->session->setFlash('error', Yii::t('app', 'Элемент не может быть удалён, так как содержит дочерние элементы. Сначала нужно удалить все дочерние элементы')); return $this->redirect(['index']); } // Запрещаем удаление корневого элемента if ($model->isRoot()) { Yii::$app->session->setFlash('error', Yii::t('app', 'Нельзя удалять корневой элемент')); return $this->redirect(['index']); } // Удаляем элемент if ($model->delete()) { Yii::$app->session->setFlash('success', Yii::t('app', 'Удаление произведено успешно')); } return $this->redirect(['index']); } public function actionMoveUp($id) { $model = $this->findModel($id); if ($prev = $model->getPrev()->one()) { $model->moveBefore($prev)->save(); $model->reorder(false); } else { Yii::$app->session->setFlash('error', Yii::t('app', 'Невозможно передвинуть элемент вверх')); } return $this->redirect(Yii::$app->request->referrer); } public function actionMoveDown($id) { $model = $this->findModel($id); if ($next = $model->getNext()->one()) { $model->moveAfter($next)->save(); $model->reorder(false); } else { Yii::$app->session->setFlash('error', Yii::t('app', 'Невозможно передвинуть элемент вниз')); } return $this->redirect(Yii::$app->request->referrer); } protected function findModel($id) { if (($model = $this->modelClass::findOne($id)) !== null) { return $model; } else { throw new NotFoundHttpException(Yii::t('app', 'Страница не найдена')); } }
}

Это будет основной контроллер для управления меню, и, так как он реализует дерево и мультиязычность, будет наследоваться от базового, который мы уже создали в предыдущем шаге: Теперь в модуле создадим файл /modules/menu/controllers/AdminController.php.

AdminController.php

<?php namespace app\modules\menu\controllers; use app\base\controllers\AdminLangTreeController as BaseController; class AdminController extends BaseController
{ public $modelClass = \app\modules\menu\models\Menu::class; public $modelClassSearch = \app\modules\menu\models\MenuSearch::class; public $modelName = 'Menu'; public $modelNameLang = 'MenuLang';
}

Тоесть, для создания CRUD контроллеров других модулей (каталога, рубрикатора итд.), которые также будут использовать дерево и мультиязычность, можно поступать аналогичным способом — расширять базовый контроллер. Как видите, код данного контроллера содержит лишь названия моделей и их классов.

Создание интерфейса для управления меню

С задачей отображения дерева отлично справляется расширение Bootstrap Treeview, которое можно достаточно гибко настроить и оно поддерживает множество удобных функций (например, поиск по дереву). Завершающий этап — создание интерфейса для управления мультиязычным деревом. Создадим индексный view для отображения самого дерева, и поместим его в /modules/menu/views/admin/index.php:

index.php

<?php use yii\helpers\Html;
use yii\grid\GridView;
use yii\widgets\ActiveForm;
use execut\widget\TreeView; $this->title = Yii::t('app', 'Меню сайта');
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="row"> <div class="col-md-6"> <div class="panel panel-primary"> <div class="panel-heading"> <?= Html::a(Yii::t('app', 'Создать'), ['create'], ['class' => 'btn btn-success btn-flat']) ?> </div> <div class="panel-body"> <?= TreeView::widget([ 'id' => 'tree', 'data' => $searchModel::treeViewData($searchModel::find()->roots()->one()), 'header' => Yii::t('app', 'Меню сайта'), 'searchOptions' => [ 'inputOptions' => [ 'placeholder' => Yii::t('app', 'Поиск по дереву') . '...' ], ], 'clientOptions' => [ 'selectedBackColor' => 'rgb(40, 153, 57)', 'borderColor' => '#fff', 'levels' => 10, 'showTags' => true, 'tagsClass' => 'badge', 'enableLinks' => true, ], ]) ?> </div> </div> </div>
</div>

Создаём в папке /modules/menu/views/admin три файла: Вот мы дошли до самого интересного этапа данного кейса: как правильно создать форму для создания/редактирования мультиязычных данных.

create.php

<?php use yii\helpers\Html; $this->title = Yii::t('app', 'Создать');
$this->params['breadcrumbs'][] = ['label' => Yii::t('app', 'Меню сайта'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title; echo $this->render('_form', [ 'model' => $model,
]);

update.php

<?php use yii\helpers\Html; $this->title = Yii::t('app', 'Обновить') . ': ' . $model->name;
$this->params['breadcrumbs'][] = ['label' => Yii::t('app', 'Меню сайта'), 'url' => ['index']];
$this->params['breadcrumbs'][] = ['label' => $model->name, 'url' => ['update', 'id' => $model->id]];
$this->params['breadcrumbs'][] = Yii::t('app', 'Обновить'); echo $this->render('_form', [ 'model' => $model,
]);

_form.php

<?php use yii\helpers\Html;
use yii\widgets\ActiveForm; if ($model->isNewRecord) { $model->status = true;
}
?>
<div class="panel panel-primary"> <?php $form = ActiveForm::begin(); ?> <div class="panel-body"> <fieldset> <legend><?= Yii::t('app', 'Общие настройки') ?></legend> <div class="row"> <div class="col-md-4"> <?php if (!$model->isRoot()) { ?> <?= $form->field($model, 'parent_id')->dropDownList($model::listTree()) ?> <?php } ?> <?= $form->field($model, 'link')->textInput(['maxlength' => true]) ?> <?= $form->field($model, 'link_attributes')->textInput(['maxlength' => true]) ?> <?= $form->field($model, 'icon_class')->textInput(['maxlength' => true]) ?> <?= $form->field($model, 'status')->checkbox() ?> </div> </div> </fieldset> <fieldset> <legend><?= Yii::t('app', 'Содержание') ?></legend> <!-- Nav tabs --> <ul class="nav nav-tabs" role="tablist"> <?php foreach (Yii::$app->urlManager->languages as $key => $language) { ?> <li role="presentation" <?= $key == 0 ? 'class="active"' : '' ?>> <a href="#tab-content-<?= $language ?>" aria-controls="tab-content-<?= $language ?>" role="tab" data-toggle="tab"><?= $language ?></a> </li> <?php } ?> </ul> <!-- Tab panes --> <div class="tab-content"> <?php foreach (Yii::$app->urlManager->languages as $key => $language) { ?> <div role="tabpanel" class="tab-pane <?= $key == 0 ? 'active' : '' ?>" id="tab-content-<?= $language ?>"> <?= $form->field($model->translate($language), "[$language]name")->textInput() ?> <?= $form->field($model->translate($language), "[$language]title")->textInput() ?> </div> <?php } ?> </div> </fieldset> </div> <div class="box-footer"> <?= Html::submitButton($model->isNewRecord ? '<i class="fa fa-plus"></i> ' . Yii::t('app', 'Создать') : '<i class="fa fa-refresh"></i> ' . Yii::t('app', 'Обновить'), ['class' => $model->isNewRecord ? 'btn btn-primary' : 'btn btn-success']) ?> <?= !$model->isNewRecord ? Html::submitButton('<i class="fa fa-save"></i> ' . Yii::t('app', 'Сохранить'), ['class' => 'btn btn-warning', 'name' => 'save']) : ''; ?> <?= !$model->isNewRecord ? Html::a('<i class="fa fa-trash"></i> ' . Yii::t('app', 'Удалить'), ['delete', 'id' => $model->id], ['class' => 'btn btn-danger', 'data' => ['confirm' => Yii::t('app', 'Вы уверены, что хотите удалить этот элемент?'), 'method' => 'post']]) : ''; ?> </div> <?php ActiveForm::end(); ?>
</div>

Язык по умолчанию должен быть первым в это массиве. Не забываем, что в приложении должен быть указан язык по умолчанию (параметр language), а в параметрах UrlManager — массив со списком языков (languages), которые мы будем использовать.

Заключение

В итоге мы должны получить следующее:

  • Готовый модуль для мультиязычного древовидного меню сайта с удобным и настраиваемым интерфейсом;
  • Базовый CRUD контроллер, который можно наследовать при создании других модулей, в которых используется дерево и мультиязычность;
  • Два трейта (мультиязычность и дерево), которые можно подключать к моделям для имплементации соответствующих функций.

Я надеюсь, что данная статья станет полезной и поможет вам в разработке новых хороших проектов на Yii2.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть