Недавно была статья про Yii, где в комментариях обсуждали специфичные для Yii компоненты, в частности GridView и ActiveForm, и фреймворк Laravel. Я подумал, а почему бы и нет.

composer create-project laravel/laravel
...
composer require yiisoft/yii2

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

Какие есть варианты


https://github.com/view-components/grids
https://github.com/assurrussa/grid-view-table
https://github.com/dwightwatson/bootstrap-form
https://github.com/core-system/bootstrap-form
https://github.com/adamwathan/bootforms
https://github.com/zofe/rapyd-laravel

Основные требования:

— верстка Bootstrap
— автоматическая обработка сортировки, пагинации, ошибок валидации формы
— минимум кода, написанного вручную
— кастомизируемость

https://github.com/view-components/grids

Хороший грид, не зависит от фреймворков. Для фреймворков есть коннекторы. Довольно громоздкая конфигурация. К тому же, похоже, выводимые данные не экранируются.

https://github.com/assurrussa/grid-view-table

Много бойлерплейта, добавляет свою глобальную функцию, какой-то странный способ рендеринга.

https://github.com/dwightwatson/bootstrap-form

Форма сама выбирает роуты для action, ошибки берутся из сессии. Но в целом близко к тому, что нужно.

Мне не нравится подход с передачей ошибок и введенных значений через сессию. Через F5 форму повторно не отправить, если обновить случайно, то все ошибки и значения стираются.

https://github.com/core-system/bootstrap-form

Какой смысл в билдере, если открывать/закрывать группу тегов надо вручную.

https://github.com/core-system/bootstrap-form

Хороший форм-билдер, практически полный аналог ActiveForm. Можно задать хранилище ошибок и введенных значений.

https://github.com/zofe/rapyd-laravel

Этот вариант кажется наиболее подходящим. Есть и грид, и формы. Грид вполне неплохой, но с формами проблема.

— Действия view/create/edit висят на одном роуте, различаются через get-параметр. Соответственно и в гриде по умолчанию URL для действий такие же.
— Это одна форма, просто различается режимом отображения. Это создает проблемы, если надо created_at/updated_at показывать только для view. И свой класс для поля надо описывать для всех 3 режимов.
— Не очень хороший код в проекте

Интеграция


Буду делать по шагам, окончательный код можно найти в конце статьи. Так как таблицы и полное редактирование часто встречаются в административном разделе, пример будет в виде админки для некоторого сайта.

Для справки, папка laravel занимает 2.6 Мб, папка symfony 4.6 Мб, папка yiisoft 3.9 Мб, зависимости Yii 5.6 Мб.

Рассмотрим простое приложение с заказами и товарами.

SQL
CREATE TABLE IF NOT EXISTS `users` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `email` varchar(100) NOT NULL,
  `password` varchar(255) NOT NULL,
  `remember_token` varchar(100) DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `users_email_unique` (`email`)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS `products` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS `orders` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(10) unsigned NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `orders-users` (`user_id`),
  CONSTRAINT `orders-users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS `order_items` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `order_id` int(10) unsigned NOT NULL,
  `product_id` int(10) unsigned NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `order_items-orders` (`order_id`),
  KEY `order_items-products` (`product_id`),
  CONSTRAINT `order_items-orders` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  CONSTRAINT `order_items-products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON UPDATE CASCADE
) ENGINE=InnoDB;


Создадим Eloquent модели и OrderController для раздела заказов. Создадим группу роутов для админки.

routes/web.php

Route::group(['prefix' => 'admin', 'as' => 'admin.', 'namespace' => 'Admin'], function () {
    Route::get('/order', 'OrderController@index')->name('order.index');
    Route::get('/order/view/{id}', 'OrderController@view')->name('order.view');
    Route::get('/order/create', 'OrderController@create')->name('order.create');
    Route::get('/order/update/{id}', 'OrderController@update')->name('order.update');
    Route::post('/order/create', 'OrderController@create');
    Route::post('/order/update/{id}', 'OrderController@update');
    Route::post('/order/delete/{id}', 'OrderController@delete')->name('order.delete');
});

Создадим Bootstrap-шаблон со ссылками на CDN.

resources/views/layouts/main.blade.php
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <link rel="icon" href="/favicon.ico">

        <title>@yield('title')</title>

        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">

        <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
        <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
        <!--[if lt IE 9]>
          <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
          <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
        <![endif]-->

        <style>body { padding-top: 60px; }</style>
    </head>
    <body>

        @include('layouts.nav')

        <div class="container">
            @yield('content')
        </div>


        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>

        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
    </body>
</html>


Делаем middleware с инициализацией и подключаем к роутам админки. Инициализация выглядит так.

routes/web.php

$initYii2Middleware = function ($request, $next)
{
    define('YII_DEBUG', env('APP_DEBUG'));
    include '../vendor/yiisoft/yii2/Yii.php';
    spl_autoload_unregister(['Yii', 'autoload']);
    $config = [
        'id' => 'yii2-laravel',
        'basePath' => '../',
        'timezone' => 'UTC',
        'components' => [
            'assetManager' => [
                'basePath' => '@webroot/yii-assets',
                'baseUrl' => '@web/yii-assets',

                'bundles' => [
                    'yii\web\JqueryAsset' => [
                        'sourcePath' => null,
                        'basePath' => null,
                        'baseUrl' => null,
                        'js' => [],
                    ],
                ],
            ],
            'request' => [
                'class' => \App\Yii\Web\Request::class,
                'csrfParam' => '_token',
            ],
            'urlManager' => [
                'enablePrettyUrl' => true,
                'showScriptName' => false,
            ],
            'formatter' => [
                'dateFormat' => 'php:m/d/Y',
                'datetimeFormat' => 'php:m/d/Y H:i:s',
                'timeFormat' => 'php:H:i:s',
                'defaultTimeZone' => 'UTC',
            ],
        ],
    ];
    (new \yii\web\Application($config));  // initialization is in constructor
    Yii::setAlias('@bower', Yii::getAlias('@vendor') . DIRECTORY_SEPARATOR . 'bower-asset');

    return $next($request);
};

Route::group(['prefix' => 'admin', 'as' => 'admin.', 'namespace' => 'Admin', 'middleware' => $initYii2Middleware], function () {
    ...
});

spl_autoload_unregister(['Yii', 'autoload']); — лучше отключить, чтобы не мешался, достаточно автозагрузчиков Laravel. Он ищет файлы через getAlias('@'...) и конечно не находит.
basePath — корневая директория приложения, при неправильной установке могут быть ошибки в путях. В этой же директории создается папка runtime.
assetManager.basePath, assetManager.baseUrl — путь и URL для публикации ассетов, название папки произвольное.
assetManager.bundles — отключаем публикацию jQuery, так как она подключается в главном шаблоне отдельно.
request — переопределяем компонент запроса, в котором заменяем работу с CSRF-токеном, название поля такое же как в настройках Laravel.
urlManager.enablePrettyUrl — надо включить, если нужны дополнительные модули типа Gii.
(new \yii\web\Application($config)) — в конструкторе происходит присвоение Yii::$app = $this;

Компонент запроса выглядит так:

app/Yii/Web/Request.php

namespace App\Yii\Web;

class Request extends \yii\web\Request
{
    public function getCsrfToken($regenerate = false)
    {
        return \Session::token();
    }
}

Токеном управляет Laravel, поэтому регенерацию обрабатывать не надо.

Грид


Теперь можно попробовать запустить. Добавим код для списка заказов.

app/Http/Controllers/Admin/OrderController.php

public function index(Request $request)
{
    $allModels = Order::query()->get()->all();
    $gridViewConfig = [
        'dataProvider' => new \yii\data\ArrayDataProvider([
            'allModels' => $allModels,
            'pagination' => ['route' => $request->route()->uri(), 'defaultPageSize' => 10],
            'sort' => ['route' => $request->route()->uri(), 'attributes' => ['id']],
        ]),
        'columns' => [
            'id',
            'user.name',
            ['label' => 'Items', 'format' => 'raw', 'value' => function ($model) {
                $html = '';
                foreach ($model->items as $item) {
                    $html .= '<div>' . htmlspecialchars($item->product->name) . '</div>';
                }
                return $html;
            }],
            'created_at:datetime',
            'updated_at:datetime',

            [
                'class' => \yii\grid\ActionColumn::class,
                'urlCreator' => function ($action, $model, $key) use ($request) {
                    $baseRoute = $request->route()->getName();

                    $baseRouteParts = explode('.', $baseRoute);
                    $baseRouteParts[count($baseRouteParts) - 1] = $action;
                    $route = implode('.', $baseRouteParts);

                    $params = is_array($key) ? $key : ['id' => (string) $key];

                    return route($route, $params, false);
                }
            ],
        ],
    ];

    return view('admin.order.index', ['gridViewConfig' => $gridViewConfig]);
}

resources/views/admin/order/index.blade.php
@extends('layouts.main')

@section('title', 'Index')

@section('content')

    <h1>Orders</h1>
    <div class="text-right">
        <a href="{{ route('admin.order.create') }}" class="btn btn-success">Create</a>
    </div>

    {!! \yii\grid\GridView::widget($gridViewConfig) !!}

@endsection


Нужно установить dataProvider.pagination.route и dataProvider.sort.route, иначе произойдет обращение к Yii::$app->controller->getRoute(), а контроллер у нас null. Аналогично с ActionColumn, только там будет проверка и InvalidParamException. URL генерируется через \yii\web\UrlManager, но результат получается такой же, как с роутингом Laravel. Можно задать менеджер через dataProvider.pagination.urlManager, если нужно.
Метки колонок пока оставим автогенерируемые.
Также надо задать некоторые стили для иконок сортировки.

Грид выводится, но так как нет фронтенд-скриптов, то кнопка Delete не работает.

Надо вывести скрипты, которые находятся в компоненте \yii\web\View. Методы renderHeadHtml(), renderBodyBeginHtml(), renderBodyEndHtml() защищены (непонятно от кого, особенно учитывая, что все переменные public). Как ни странно, есть повод применить антипаттерн «public morozov». Или можно просто скопипастить их в главный шаблон.

app/Yii/Web/View.php

namespace App\Yii\Web;

class View extends \yii\web\View
{
    public function getHeadHtml()
    {
        return parent::renderHeadHtml();
    }

    public function getBodyBeginHtml()
    {
        return parent::renderBodyBeginHtml();
    }

    public function getBodyEndHtml($ajaxMode = false)
    {
        return parent::renderBodyEndHtml($ajaxMode);
    }

    public function initAssets()
    {
        \yii\web\YiiAsset::register($this);

        ob_start();

        $this->beginBody();
        $this->endBody();

        ob_get_clean();
    }
}

В Yii регистрация ассетов происходит в функции endBody(), а также весь рендеринг оборачивается в буфер, в котором потом производится замена магических констант CDATA на реальные ассеты. Эмуляция этого поведения находится в функции initAssets(). Заменять мы ничего не будем, нам нужно просто чтобы были заполнены свойства $this->js, $this->css и другие.

routes/web.php
'components' => [
    ...
    'view' => [
        'class' => \App\Yii\Web\View::class,
    ],
],



resources/views/admin/order/index.blade.php
<!DOCTYPE html>
<html lang="en">
    <head>
        ...

        <?php $view = \Yii::$app->getView(); $view->initAssets(); ?>
        {!! \yii\helpers\Html::csrfMetaTags() !!}
        {!! $view->getHeadHtml() !!}
    </head>
    <body>
        {!! $view->getBodyBeginHtml() !!}

        ...

        {!! $view->getBodyEndHtml() !!}
    </body>
</html>


Вызов Html::csrfMetaTags() нужен, так как скрипт yii.js берет csrf-токен из HTML страницы.

ArrayDataProvider работает, но надо сделать аналог ActiveDataProvider, чтобы получать из базы только то что нужно.

app/Yii/Data/EloquentDataProvider.php

class EloquentDataProvider extends \yii\data\BaseDataProvider
{
    public $query;

    public $key;

    protected function prepareModels()
    {
        $query = clone $this->query;

        if (($pagination = $this->getPagination()) !== false) {
            $pagination->totalCount = $this->getTotalCount();
            if ($pagination->totalCount === 0) {
                return [];
            }
            $query->limit($pagination->getLimit())->offset($pagination->getOffset());
        }

        if (($sort = $this->getSort()) !== false) {
            $this->addOrderBy($query, $sort->getOrders());
        }

        return $query->get()->all();
    }

    protected function prepareKeys($models)
    {
        $keys = [];
        if ($this->key !== null) {
            foreach ($models as $model) {
                $keys[] = $model[$this->key];
            }

            return $keys;
        } else {
            $pks = $this->query->getModel()->getKeyName();

            if (is_string($pks)) {
                $pk = $pks;
                foreach ($models as $model) {
                    $keys[] = $model[$pk];
                }
            } else {
                foreach ($models as $model) {
                    $kk = [];
                    foreach ($pks as $pk) {
                        $kk[$pk] = $model[$pk];
                    }
                    $keys[] = $kk;
                }
            }

            return $keys;
        }
    }

    protected function prepareTotalCount()
    {
        $query = clone $this->query;
        $query->orders = null;
        $query->offset = null;

        return (int) $query->limit(-1)->count('*');
    }

    protected function addOrderBy($query, $orders)
    {
        foreach ($orders as $attribute => $order) {
            if ($order === SORT_ASC) {
                $query->orderBy($attribute, 'asc');
            } else {
                $query->orderBy($attribute, 'desc');
            }
        }
    }
}

app/Http/Controllers/Admin/OrderController.php
    'dataProvider' => new \App\Yii\Data\EloquentDataProvider([
        'query' => Order::query(),
        'pagination' => ['route' => $request->route()->uri(), 'defaultPageSize' => 10],
        'sort' => ['route' => $request->route()->uri(), 'attributes' => ['id']],
    ]),


Метки и фильтры


Нужно сделать базовую модель, унаследованную от \yii\base\Model, которая будет возвращать гриду метки для колонок и правила полей для фильтрации. Для этого есть параметр filterModel. Сделаем ее конфигурируемой через конструктор.

app/Yii/Data/FilterModel.php
namespace App\Yii\Data;

use App\Yii\Data\EloquentDataProvider;
use Route;

class FilterModel extends \yii\base\Model
{
    protected $labels;
    protected $rules;
    protected $attributes;


    public function __construct($labels = [], $rules = [])
    {
        parent::__construct();

        $this->labels = $labels;
        $this->rules = $rules;

        $safeAttributes = $this->safeAttributes();
        $this->attributes = array_combine($safeAttributes, array_fill(0, count($safeAttributes), null));
    }

    public function __get($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        } else {
            return parent::__get($name);
        }
    }

    public function __set($name, $value)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = $value;
        } else {
            parent::__set($name, $value);
        }
    }

    public function rules()
    {
        return $this->rules;
    }

    public function attributeLabels()
    {
        return $this->labels;
    }

    public function initDataProvider($query, $sortAttirbutes = [], $route = null)
    {
        if ($route === null) { $route = Route::getCurrentRoute()->uri(); }
        $dataProvider = new EloquentDataProvider([
            'query' => $query,
            'pagination' => ['route' => $route],
            'sort' => ['route' => $route, 'attributes' => $sortAttirbutes],
        ]);

        return $dataProvider;
    }

    public function applyFilter($params)
    {
        $query = null;

        $dataProvider = $this->initDataProvider($query);

        return $dataProvider;
    }
}


Можно унаследоваться и определить специализированную модель, и поместить все туда.

namespace App\Forms\Admin;

use App\Yii\Data\FilterModel;

class OrderFilter extends FilterModel
{
    public function rules()
    {
        return [
            ['id', 'safe'],
            ['user.name', 'safe'],
        ];
    }

    public function attributeLabels()
    {
        return [
            'id' => 'ID',
            'created_at' => 'Created At',
            'updated_at' => 'Updated At',
            'user.name' => 'User',
        ];
    }

    public function applyFilter($params)
    {
        $this->load($params);

        $query = \App\Models\Order::query();
        $query->join('users', 'users.id', '=', 'orders.user_id')->select('orders.*');

        if ($this->id) $query->where('orders.id', '=', $this->id);
        if ($this->{'user.name'}) $query->where('users.name', 'like', '%'.$this->{'user.name'}.'%');

        $sortAttributes = [
            'id',
            'user.name' => ['asc' => ['users.name' => SORT_ASC], 'desc' => ['users.name' => SORT_DESC]],
        ];

        $dataProvider = $this->initDataProvider($query, $sortAttributes);
        $dataProvider->pagination->defaultPageSize = 10;

        if (empty($dataProvider->sort->getAttributeOrders())) {
            $dataProvider->query->orderBy('orders.id', 'asc');
        }

        return $dataProvider;
    }
}

app/Http/Controllers/Admin/OrderController.php
public function index(Request $request)
{
    $filterModel = new \App\Forms\Admin\OrderFilter();

    $dataProvider = $filterModel->applyFilter($request);

    $gridViewConfig = [
        'dataProvider' => $dataProvider,
        'filterModel' => $filterModel,
        ...
    ];
    ...
}


Есть небольшая проблема, что если задана filterModel, но нет ни одного поля для фильтра, то все равно отображается строка с пустыми ячейками. В этом случае можно метки вручную проставить. Хотя лучше было бы, если бы в самом компоненте была такая проверка.

Просмотр


Тут делаем аналогично, настройки колонок можно скопировать из грида. Товары в заказе сделаем отдельным гридом на странице просмотра. Метки тоже пока оставим автогенерируемые.

app/Http/Controllers/Admin/OrderController.php

public function view($id)
{
    $model = Order::findOrFail($id);

    $detailViewConfig = [
        'model' => $model,
        'attributes' => [
            'id',
            'user.name',
            'created_at:datetime',
            'updated_at:datetime',
        ],
    ];

    $gridViewConfig = [
        'dataProvider' => new \App\Yii\Data\EloquentDataProvider([
            'query' => $model->items(),
            'pagination' => false,
            'sort' => false,
        ]),
        'layout' => '{items}{summary}',
        'columns' => [
            'id',
            'product.name',
            'created_at:datetime',
            'updated_at:datetime',
        ],
    ];

    return view('admin.order.view', ['model' => $model, 'detailViewConfig' => $detailViewConfig, 'gridViewConfig' => $gridViewConfig]);
}

resources/views/admin/order/view.blade.php
@extends('layouts.main')

@section('title', 'Index')

@section('content')

    <h1>Order: {{ $model->id }}</h1>
    <p class="text-right">
        <a href="{{ route('admin.order.update', ['id' => $model->id]) }}" class="btn btn-primary">Update</a>
        <a href="{{ route('admin.order.delete', ['id' => $model->id]) }}" class="btn btn-danger" data-confirm="Are you sure?" data-method="post">Delete</a>
    </p>

    {!! \yii\widgets\DetailView::widget($detailViewConfig) !!}

    <h2>Order Items</h2>

    {!! \yii\grid\GridView::widget($gridViewConfig) !!}

@endsection



Создание / Обновление


Сначала нужно сделать модель формы, враппер для Eloquent моделей, унаследованный от \yii\base\Model, чтобы компонент ActiveForm мог вызывать нужные методы.

app/Yii/Data/FormModel.php
namespace App\Yii\Data;

use Illuminate\Database\Eloquent\Model as EloquentModel;

class FormModel extends \yii\base\Model
{
    protected $model;
    protected $labels;
    protected $rules;
    protected $attributes;

    public function __construct(EloquentModel $model, $labels = [], $rules = [])
    {
        parent::__construct();

        $this->model = $model;
        $this->labels = $labels;
        $this->rules = $rules;

        $fillable = $model->getFillable();
        $attributes = [];
        foreach ($fillable as $field) {
            $attributes[$field] = $model->$field;
        }

        $this->attributes = $attributes;
    }

    public function getModel()
    {
        return $model;
    }

    public function __get($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        } else {
            return $this->model->{$name};
        }
    }

    public function __set($name, $value)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = $value;
        } else {
            $this->model->{$name} = $value;
        }
    }

    public function rules()
    {
        return $this->rules;
    }

    public function attributeLabels()
    {
        return $this->labels;
    }

    public function save()
    {
        if (!$this->validate()) {
            return false;
        }

        $this->model->fill($this->attributes);
        return $this->model->save();
    }
}


Теперь можно сделать редактирование.

app/Http/Controllers/Admin/OrderController.php

    public function create(Request $request)
    {
        $model = new Order();
        $formModel = new \App\Yii\Data\FormModel(
            $model,
            ['user_id' => 'User'],
            [['user_id', 'safe']]
        );

        if ($request->isMethod('post')) {
            if ($formModel->load($request->input()) && $formModel->save()) {
                return redirect()->route('admin.order.view', ['id' => $model->id]);
            }
        }

        return view('admin.order.create', ['formModel' => $formModel]);
    }

    public function update($id, Request $request)
    {
        $model = Order::findOrFail($id);
        $formModel = new \App\Yii\Data\FormModel(
            $model,
            ['user_id' => 'User'],
            [['user_id', 'safe']]
        );

        if ($request->isMethod('post')) {
            if ($formModel->load($request->input()) && $formModel->save()) {
                return redirect()->route('admin.order.view', ['id' => $model->id]);
            }
        }

        return view('admin.order.update', ['formModel' => $formModel]);
    }

resources/views/admin/order/_form.blade.php
<?php $form = \yii\widgets\ActiveForm::begin() ?>

    {!! $form->field($formModel, 'user_id')->dropDownList(\App\User::pluck('name', 'id'), ['prompt' => '']) !!}

    <button type="submit" class="btn btn-primary">Submit</button>

<?php \yii\widgets\ActiveForm::end() ?>


Правила валидации задаются в стиле Yii. Если нужно, можно переопределить метод validate() и вызывать там валидатор Laravel. В данном примере мы этого делать не будем.

Blade не разрешает объявлять переменные. А ActiveForm::begin() и выводит теги и возвращает значение. Можно явно написать тег <?php ?>, можно сделать новый тег через Blade::extend(), как советуют здесь, можно сделать обертку для ActiveForm. Пока оставим <?php ?>.

Как и в случае с фильтром, можно унаследоваться от FormModel и поместить все объявления туда.

app/Forms/Admin/OrderForm.php
namespace App\Forms\Admin;

class OrderForm extends FormModel
{
    public function rules()
    {
        return [
            ['user_id', 'safe'],
        ];
    }

    public function attributeLabels()
    {
        return [
            'id' => 'ID',
            'user_id' => 'User',
            'created_at' => 'Created At',
            'updated_at' => 'Updated At',
            'user.name' => 'User',
        ];
    }
}



Метки на странице просмотра


Теперь можно использовать OrderForm, чтобы задать метки в методе app/Http/Controllers/Admin/OrderController.php.

$formModel = new \App\Forms\Admin\OrderForm($model);

$detailViewConfig = [
    'model' => $formModel,
    ...
];


Удаление


Тут все просто.

app/Http/Controllers/Admin/OrderController.php

public function delete($id)
{
    $model = Order::findOrFail($id);
    $model->delete();

    return redirect()->route('admin.order.index');
}

Дополнения


Можно подключить Gii. В чистом виде он не нужен, но можно брать из генератора моделей правила валидации формы и метки для полей, чтобы не генерировать их вручную. Или можно свой генератор написать.

composer require yiisoft/yii2-gii --dev

routes/web.php
    $config = [
        'components' => [
            ...
            'db' => [
                'class' => \yii\db\Connection::class,
                'dsn' => 'mysql:host='.env('DB_HOST', 'localhost')
                    .';port='.env('DB_PORT', '3306')
                    .';dbname='.env('DB_DATABASE', 'forge'),
                'username' => env('DB_USERNAME', 'forge'),
                'password' => env('DB_PASSWORD', ''),
                'charset' => 'utf8',
            ],
            ...
        ],
    ];

    if (YII_DEBUG) {
        $config['modules']['gii'] = ['class' => \yii\gii\Module::class];
        $config['bootstrap'][] = 'gii';
    }

    (new \yii\web\Application($config));  // initialization is in constructor
    Yii::setAlias('@bower', Yii::getAlias('@vendor') . DIRECTORY_SEPARATOR . 'bower-asset');
    Yii::setAlias('@App', Yii::getAlias('@app') . DIRECTORY_SEPARATOR . 'App');

    ...

    Route::any('gii{params?}', function () {
        $request = \Yii::$app->getRequest();
        $request->setBaseUrl('/admin');
        \Yii::$app->run();
        return null;
    })->where('params', '(.*)');


Yii::setAlias('@App') — путь к файлам определяется через Yii::getAlias('@'...), поэтому для класса App\Models\Order будет проверяться путь '@App/Models/Order.php'.

setBaseUrl('/admin') — нужно, чтобы роутинг Yii обрабатывал только часть после '/admin'.

С Yii::setAlias('@App') и ['Yii', 'autoload'] есть такая проблема. Если не отключить автозагрузчик, то при неправильном названиии класса или неймспейса в существующем файле происходит ошибка, которая неправильно обрабатывается. Происходит это так. Он подключает файл, но потом не находит класс и бросает исключение UnknownClassException. Вызывается автозагрузчик Laravel, который проверяет фасады и алиасы и тоже ничего не находит. Потом вызывается автозагрузчик Composer, который снова подключает файл, и возникает уже другая ошибка 'Cannot declare class '...', because the name is already in use'. Приложение падает с ошибкой 500 без записи в лог.

Gii будет работать, несмотря на то, что мы отключили jQuery, так как у него свой шаблон отображения, и поэтому он сбрасывает настройки ассетов приложения.

vendor\yiisoft\yii2-gii\Module.php
protected function resetGlobalSettings()
{
    if (Yii::$app instanceof \yii\web\Application) {
        Yii::$app->assetManager->bundles = [];
    }
}


Можно вынести конфигурацию ActionColumn в отдельный класс, чтобы не копировать в разные гриды.

app/Yii/Widgets/ActionColumn.php
namespace App\Yii\Widgets;

use URL;
use Route;

class ActionColumn extends \yii\grid\ActionColumn
{
    public $keyAttribute = 'id';
    public $baseRoute = null;
    public $separator = '.';

    /**
     * Overrides URL generation to use Laravel routing system
     *
     * @inheritdoc
     */
    public function createUrl($action, $model, $key, $index)
    {
        if (is_callable($this->urlCreator)) {
            return call_user_func($this->urlCreator, $action, $model, $key, $index, $this);
        } else {
            if ($this->baseRoute === null) {
                $this->baseRoute = Route::getCurrentRoute()->getName();
            }

            $baseRouteParts = explode($this->separator, $this->baseRoute);
            $baseRouteParts[count($baseRouteParts) - 1] = $action;
            $route = implode($this->separator, $baseRouteParts);

            $params = is_array($key) ? $key : [$this->keyAttribute => (string) $key];

            return URL::route($route, $params, false);
        }
    }
}


Можно сделать обертку для ActiveForm, куда поместить вызов виждета, и передавать модель в конструктор. Это позволит убрать прямые теги <?php ?> и передачу модели в каждое поле. Также туда можно добавлять дополнительные методы для инициализации сторонних виджетов полей типа Select2. Такой билдер можно использовать и в проектах на Yii.

app/Yii/Widgets/FormBuilder.php
namespace App\Yii\Widgets;

use yii\widgets\ActiveForm;
use yii\helpers\Html;

class FormBuilder extends \yii\base\Component
{
    protected $model;
    protected $form;


    public function __construct($model)
    {
        $this->model = $model;
    }

    public function getModel()
    {
        return $this->model;
    }

    public function setModel($model)
    {
        $this->model = $model;
    }

    public function getForm()
    {
        return $this->form;
    }

    public function open($params = ['successCssClass' => ''])
    {
        $this->form = ActiveForm::begin($params);
    }

    public function close()
    {
        ActiveForm::end();
    }

    public function field($attribute, $options = [])
    {
        return $this->form->field($this->model, $attribute, $options);
    }

    public function submitButton($content, $options = ['class' => 'btn btn-primary'])
    {
        return Html::submitButton($content, $options);
    }
}


resources/views/admin/order/_form.blade.php
{!! $form->open() !!}

    {!! $form->field('user_id')->dropDownList(
        \App\User::pluck('name', 'id'),
        ['prompt' => ''])
    !!}

    {!! $form->submitButton('Submit'); !!}

{!! $form->close() !!}



Исходный код


Исходный код можно найти здесь. Все шаги сделаны отдельными коммитами. Есть миграции и тестовые данные.

php artisan migrate:refresh --seed

Обертки находятся в папке app/Yii.

Обязательные:

App\Yii\Web\Request
App\Yii\Data\EloquentDataProvider
App\Yii\Data\FormModel

Без остальных можно обойтись, но с ними удобнее:

App\Yii\Data\FilterModel
App\Yii\Web\View
App\Yii\Widgets\ActionColumn
App\Yii\Widgets\FormBuilder

Также, думаю, это неплохой пример для сравнения разных реализаций. Если есть время и желание, приводите в комментариях свою реализацию этой админки на другом стеке технологий.
Поделиться с друзьями
-->

Комментарии (23)


  1. Agel_Nash
    31.07.2017 16:25
    +7

    О! Мсье знает толк в извращениях!


  1. MetaDone
    31.07.2017 16:34
    +1

    жду интеграции SonataAdminBundle в Phalcon


  1. uonick
    31.07.2017 16:55
    +1

        Route::get('/order', 'OrderController@index')->name('order.index');
        Route::get('/order/view/{id}', 'OrderController@view')->name('order.view');
        Route::get('/order/create', 'OrderController@create')->name('order.create');
        Route::get('/order/update/{id}', 'OrderController@update')->name('order.update');
        Route::post('/order/create', 'OrderController@create');
        Route::post('/order/update/{id}', 'OrderController@update');
        Route::post('/order/delete/{id}', 'OrderController@delete')->name('order.delete');
    


    Route::resource('order', 'OrderController');


    1. michael_vostrikov
      31.07.2017 16:59

      Согласен, но у него HTTP-методы немного другие, в верстке надо вручную прописывать.


  1. myrkoxx
    31.07.2017 17:29

    image


    1. michael_vostrikov
      31.07.2017 17:44
      +2

      Просто почему бы и нет) А вообще говоря, вполне рабочий вариант.
      Притянуто за уши, но все-таки:


      • минимализм в конфигурации, указываем только то что нужно
      • экономия времени при разработке — скопировали, поменяли, добавили
      • минимум новых зависимостей, тот же язык программирования
      • в Laravel и так часть Symfony, почему бы компоненты из другого фреймфорка не использовать

      Приведите свой пример, как вы решаете эту задачу.


      1. MetaDone
        31.07.2017 18:07
        +1

        в Laravel и так часть Symfony, почему бы компоненты из другого фреймфорка не использовать

        https://github.com/michael-vostrikov/laravel-yii2/blob/master/composer.json#L11
        совсем маленькая часть yii используется


        1. michael_vostrikov
          31.07.2017 18:41
          +1

          Логически да, аж целый фреймворк, а в байтах часть Symfony занимает больше, чем весь Yii. Хотя согласен, было бы лучше, если бы эти компоненты были отдельными проектами.


      1. myrkoxx
        31.07.2017 21:02
        -1

        Нарываясь на минуса :)

        Я не использую Laravel для разработки. Мне после Zend и Symfony он не зашел (написали несколько проектов на Laravel). Но если абстрагироваться, то взял бы Symfony + EasyAdmin или Symfony + Sonata. Если есть время, то лучше %ВАШ_ФРЕЙМВОРК% + фронтенд на React, Vue, Angular2+, Ember.

        Если про решение на Laravel, то наверно взял етот grid, хотя бы потому, что тянуть два фреймворка ето лишнее.

        Лично для меня server-side рендеринг уже прошлое.Надо отделять мух от котлет. Чисто мое мнение.


        1. michael_vostrikov
          31.07.2017 21:38

          Дак необязательно Laravel, на Реакте покажите. Просто интересно, насколько сложнее/легче будет сделать на других компонентах.


        1. hlogeon
          01.08.2017 13:57

          Интересно, почему мне после Zend и Symfony Laravel как раз таки очень хорошо зашел?)


  1. Samouvazhektra
    31.07.2017 21:46

    кгхм… за упорство +1 однозначно… но в общем-то необходимости не вижу… такие вещи решаются с помощью параметризированых инклудов или компоненто-слотов, + пара строк в app.js с автосабмитом столбцовых фильтров и data-method, + немножечко допиленный FilterFormRequest и SearchModel по первой конечно чуть больше времени отнимает, но только с непривычки. Зато потом плюсы — переносимость не завязанная на неймспейсы, менее геморройная смена css-фреймворка, vue-фикация шаблона, и никаких головоломок при необходимости добавления разворачивающихся подстолбцов для связей или группировок по алфавиту/дате и т.п. и прочих нестандартных фенек.

    (в общем-то в yii точно так же можно без виджетов обходиться с помощью партиалов и блоков… но как-то принято на каждый чих делать виджет-класс)


    1. michael_vostrikov
      31.07.2017 21:54

      А не могли бы вы показать примерный код, что будет на сервере и на клиенте?


      1. Samouvazhektra
        01.08.2017 03:41

        https://gist.github.com/Insolita/a3d3acfa8713850dcd70a9bb4d880dd3
        В общем-то вьюха с таблицей выходит не многословнее конфига GridView и по сути мало чем отличается


        1. michael_vostrikov
          01.08.2017 21:12

          Спасибо. Я имел в виду сущности и интерфейс, описанные в статье, чтобы можно было сравнить, но так тоже подойдет.


          вьюха с таблицей выходит не многословнее конфига GridView

          У вас на конкретный грид выходит (20+30+10) + 140 = 60 + 140 = 200 строк.
          У меня (40+50) + 20 = 90 + 20 = 110 строк.
          В два раза меньше кода, написанного вручную, который надо поддерживать.


          При этом в переиспользуемом коде есть ограничение в виде одного periodAttribute. А если мне надо период и по created_at и по last_login_at?


          переносимость не завязанная на неймспейсы

          Не очень понял, что это значит, но вроде у меня нет в коде чего-то, завязанного на неймспейсы. Их использование такое же, как и для любых других сторонних компонентов.


          смена css-фреймворка

          Смена css-фреймворка это не та вещь, которая происходит часто, а вот гриды и формы нужны для каждой новой сущности.


          никаких головоломок при необходимости добавления разворачивающихся подстолбцов

          За исключением того, что все это надо делать вручную, чего я и пытался избежать, подключая сторонние компоненты.


          но как-то принято на каждый чих делать виджет-класс

          Это делают, когда надо инкапсулировать логику по инициализации или получению данных. И хардкодить длинные пути, уходящие в vendor, тоже не очень красиво. Но если логически это именно часть верстки, то и делают через include или болки. Например, верстку формы для create и update.


  1. Samouvazhektra
    02.08.2017 08:31

    При этом в переиспользуемом коде есть ограничение в виде одного periodAttribute. А если мне надо период и по >created_at и по last_login_at?
    Это просто гист выдранный с конкретного проекта… он совсем простой и такого решения за глаза хватает, можно periodAttributes сделать массивом и каждый проверять.
    Как вы количество строк посчитали не совсем поняла — но штука в том что пока у вас в примере самый простой грид… а если добавить аякс-свитчер, сделать редактируемый инпут, модалку или когда вам понадобится сколь-нибудь нестандартный столбец, агрегация суммы, массовые операции, или например выбор отображаемых столбцов — у вас все это быстро разрастётся плюс обрастет дополнительными костылями, за счет специфики регистрации скриптов в yii -виджетах. Штука что виджеты yii получаются с оверхедом — и как презентер, и как получатель данных (в ларе для этого предусмотрен шаринг через view-composer), и как кусок переиспользуемой вьюхи, и как регистратор скриптов. Они хороши для кодеров, но часто смешивают логику и верстку-дизайн.

    Посмотрите https://backpackforlaravel.com для админки — там и гриды и формы и уже куча всего интегрированного.


    1. michael_vostrikov
      02.08.2017 11:04

      Угу, "Buy license", 14 Мб, 1000 файлов, большая конфигурация и куча документации по настройке. Она сама как фреймворк.


      Как вы количество строк посчитали не совсем поняла

      concrete_filter.php + concrete_search.php + controller_action.php + верстка. Хотя с версткой я немного ошибся, есть общие части, но с захардкожеными id-шниками и которые нужно подключать строго внутри тега tr. Это тоже то, чего стоит избегать, особенно в однотипном коде наподобие разделов админки, где бывает много копипасты.


      но штука в том что пока у вас в примере самый простой грид… а если добавить ...

      Так я ж о том и прошу) Покажите сложный пример, где проявятся преимущества вашего подхода.


      часто смешивают логику и верстку-дизайн

      У представления есть своя логика, не надо путать ее с бизнес-логикой. Логику представления необязательно писать на javascript, с серверным рендерингом этим занимается серверный язык.


      1. Samouvazhektra
        02.08.2017 15:20

        Угу, "Buy license", 14 Мб, 1000 файлов, большая конфигурация и куча документации по настройке. Она сама как >фреймворк.

        Or use for free in your non-commercial applications


        есть общие части, но с захардкожеными id-шниками

        они не захардкоженные — там значение по умолчанию указывается. Копипасты нет — реюзабельное инклудится, базовые шаблоны сохранены в php-storm как шаблоны,


        Так я ж о том и прошу) Покажите сложный пример, где проявятся преимущества вашего подхода.

        Я думаю принцип и так вполне понятен… вместо yiigrid — столбцов для часто-используемого специфического функционала используются view-partial группировки/суммирование и прочее — за счет фич eloquent\collection и loop.index blade
        преимущество — чистые скрипты и вьюхи удобные и понятные фронтендеру, чистые классы, никаких сторонних фреймворков


        А вот и покажите серверный+клиентский код для описанной функциональности на любой из этих библиотек)

        С этим лучше на тостер


        большая конфигурация и куча документации по настройке

        Предполагаю что вам просто не хочется изучать что-то новое, и вы пытаетесь тащить старое и привычное, и у Вопрос только в том — почему не делать на yii?


        Для формбилдера кстати есть https://laravelcollective.com/docs/5.4/html


        1. michael_vostrikov
          02.08.2017 16:04

          Зачем мне non-commercial, если я делаю закрытую админку для заказчика. Мне самому обычно хватает клиента к SQL.


          они не захардкоженные

          <div id="grid_filter"> ... <form id="grid_filter_form">
          Но даже если сделать как везде, то будет сильная связанность между 2 разными вьюхами. Нужно знать негласное правило, "если я подключаю вот этот файл с вот таким id, то при подключении того файла надо указывать тот же id".


          Я думаю принцип и так вполне понятен

          Пока что я заметил, что надо много кода писать вручную.


          за счет фич eloquent\collection преимущество

          Дак и у меня модели Eloquent.


          С этим лучше на тостер

          Не вижу связи. Я знаю способы это сделать без тостеров. Но мне говорят, что можно сделать лучше, вот я и прошу показать это на деле. Пока что согласились только вы.


          Предполагаю что вам просто не хочется изучать что-то новое

          Суть не в этом. Есть разные способы это сделать, и с GridView это быстро и немного, причем это не тяп-ляп, а получается готовый к продакшену код.
          Я же не просто так попросил привести другую реализацию, наоборот, я хочу узнать что-то новое, что будет полезно. Но то, что я вижу в документации, оставляет желать лучшего.


          Вопрос только в том — почему не делать на yii?

          У Yii свои недостатки, и к тому же тут сравнение не фреймворков в целом, а отдельных компонентов. А еще может быть уже существущий проект на Laravel.


          Для формбилдера кстати есть https://laravelcollective.com/docs/5.4/html

          Это аналог yii\helpers\Html. Для Bootstrap-форм надо много писать вручную. Зачем, если есть готовые компоненты? К тому же там много магии типа "If there is an item in the Session flash data matching the input name, that will take precedence over the model's value".
          Если бы для Laravel были удобные аналоги, мне бы в голову не пришло интегрировать 2 фреймворка)


          1. Samouvazhektra
            02.08.2017 20:36

            проблема в написании html-кода?
            вообще-то он отлично пишется — отличный автокомплит + livetemplates + по css-классам в том числе


            <a href="{{someurl}}" class="some class" data-role="some" id="some"> Some Name</a>
            <input type="text" value="foobar" id="foo_id">

            пишется однофигственно


            Html::a('Some Name',$someUrl,['id'=>'some','class'=>'some class','data'=>['role'=>'some']]);
            Html::input('text','foobar',['id'=>'foo_id']);

            If there is an item in the Session flash data matching the input name, that will take precedence over the model's value

            Это фича old() ларавела https://laravel.com/docs/5.4/requests#old-input — так как там прицип валидации завязан на request т.е. модели не валидные данные не присваиваются вообще в отличие от yii


            … Но даже если сделать как везде, то будет сильная связанность между 2 разными вьюхами. Нужно знать > негласное правило, «если я подключаю вот этот файл с вот таким id, то при подключении того файла надо указывать тот же id».

            C чего вы вообще взяли что они связаны? там где нужна конкретная связь по id он параметризуется. Нет никаких негласных правил


            Просто ларавельщики привыкли к datatables https://datatables.yajrabox.com самый популярный пакет


            1. michael_vostrikov
              03.08.2017 08:02

              Проблема в том, что в обоих случаях это однотипный код, который надо написать, поддерживать, и копировать в другой аналогичный раздел.


              модели не валидные данные не присваиваются вообще в отличие от yii

              Они должны быть не в модели, обозначающей сущность, а в модели формы. В форму на клиенте можно ввести невалидные данные, модель этой формы на сервере должна отражать положение дел на клиенте. На то она и модель. И валидация должна быть в ней, а не в сущности. В Zend, если я правильно помню, аналогичный подход, только более громоздкий, с гидрированием и фабриками.


              C чего вы вообще взяли что они связаны?

              <button class="btn btn-sm bg-purple" form="{{$formId or 'grid_filter'}}"><i class="fa fa-search"></i> Найти</button>
              Потому что в коде так написано. Кнопка в одном partial ссылается на форму из другого partial. Id параметризуется, и надо знать, что если тут я в параметрах передал grid_filter2, значит там мне тоже надо передавать grid_filter2.


              https://datatables.yajrabox.com

              Выглядит неплохо, спасибо за информацию, посмотрю на досуге. Только немного смущает, что на ajax везде надо проверять.


  1. 1dnmr
    02.08.2017 10:38

    а зачем?
    есть же куча jQuery гридов. Kendo UI, jqueryui и так далее.


    1. michael_vostrikov
      02.08.2017 11:12

      А вот и покажите серверный+клиентский код для описанной функциональности на любой из этих библиотек) Но вообще я специально поставил статью в раздел "Ненормальное программирование".
      А с другой стороны, это вполне рабочее решение с минимумом настройки на официальных компонентах от разработчиков фреймворка.