Что такое AdminYard?

AdminYard — это библиотека для создания админок на PHP, которую я недавно написал с нуля. Зачем, спросите вы, если вокруг и так полно админок? Я искал библиотеку, которая бы встроилась в существующий легаси-проект и не притащила с собой кучу новых тяжелых зависимостей вроде фреймворков, шаблонизаторов и ORM. Ничего подходящего не нашел: мне попадались либо библиотеки из экосистемы фреймворков, либо мутные платные скрипты.

Я много работал с бандлом для Symfony EasyAdmin, еще начиная с первой версии. Из него позаимствовал общий подход и идею описания конфигурации. Также реализовал в своей библиотеке те фичи, которых мне не хватало в EasyAdmin и для которых приходилось придумывать костыли.

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

Страница списка сущностей Comment с фильтром и инлайн-редактированием поля Name
Страница списка сущностей Comment с фильтром и инлайн-редактированием поля Name

Фичи AdminYard

CRUD-операции. Для начала работы в конфиге AdminYard описываются те таблицы и поля из базы данных, с которыми будет вестись работа в админке. После этого для каждой описанной сущности появляются 4 экрана:

  • list — cписок всех сущностей,

  • show — просмотр одной сущности,

  • new — форма создания,

  • edit — форма редактирования.

Отображение каждого поля на этих экранах настраивается независимо. Для определенных сущностей ненужные экраны можно отключить. Ячейки в таблице списка сущностей можно сделать редактируемыми прямо в списке сущностей, без перехода к форме редактирования (in-place edit).

Формы создания и редактирования сущностей создаются автоматически на основе конфига. К полям на формах создания и редактирования можно указать правила валидации.

Форма редактирования с отображением ошибки валидации
Форма редактирования с отображением ошибки валидации

Связи между сущностями и виртуальные поля. Если в вашем проекте связь многие-к-одному сделана стандартным образом, через хранение parent_id в дочерней сущности, то такая связь указывается в конфиге AdminYard и работает из коробки. В колонке таблицы со списком сущностей вместо значения parent_id отображается ссылка на саму родительскую сущность, а при создании или редактировании дочерней сущности родительская сущность выбирается из списка.

Поддержки связи многие-ко-многим нет, но есть точки расширения для её реализации в вашем коде. Я сделал так, потому что в отличие от стандартного поля parent_id для связи многие-к-одному, связь многие-ко-многим хранится в отдельной таблице. Для изменения записей в ней нет стандартных интерфейсных подходов, интерфейс часто определяется бизнес-логикой. Рассмотрим пример связи многие-ко-многим: посты и теги в блоге. В интерфейсе удобно редактировать эту связь как дополнительное поле со списком тегов на форме редактирования поста. Такие поля описываются в конфиге как виртуальные поля. Чтобы они заработали, требуется написать обработчики событий, которые и будут сохранять содержимое виртуальных полей в другие таблицы.

Поле со списком тегов на форме редактирования поста - виртуальное, на самом деле это связь многие-ко-многим
Поле со списком тегов на форме редактирования поста - виртуальное, на самом деле это связь многие-ко-многим

Фильтры для списков сущностей. Это то, чего мне не хватало в EasyAdmin. Между полями фильтра и полями сущностей не всегда есть прямое соответствие. Например, в фильтре может присутствовать общий поиск по нескольким текстовым колонкам или, наоборот, два поля для выбора интервала времени при фильтрации по одной колонке. Чтобы задавать такое соответствие, потребуется написать фрагмент SQL-запроса.

Выбранный фильтр сохраняется в отдельном хранилище, например, в сессии. Это удобно, чтобы на какой-то странице админки не приходилось каждый раз выбирать одни и те же условия.

Контроль доступа на уровне строк. AdminYard позволяет указать условия в SQL-запросах для ограничения доступа к некоторым строкам на чтение и на запись. Вот как выглядит искусственный пример, в котором к записям с id от 31 до 35 ограничен доступ на запись, а к записям с id от 40 до 41 ограничен доступ на чтение.

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

Шаблоны и их переопределение. Шаблоны в AdminYard — это обычные php-файлы. В комплекте есть шаблоны по умолчанию, и их можно переопределить независимо для каждой сущности. Например, в блоге форму редактирования поста надо существенно доработать: сверстать по красивому макету, добавить автосохранение в localStorage, подключить продвинутый редактор вместо стандартной textarea. А форма редактирования пользователей может остаться стандартной, так как её используют редко. Всё это делается с помощью переопределения встроенных шаблонов.

Защита от CSRF во всех формах. Мелочь, а приятно.

Чего нет в AdminYard

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

Нет авторизации. Она должна быть внешняя: пользователь и права должны проверяться до того, как управление будет передано в AdminYard. Если требуются разные привилегии пользователей в зависимости от ролей, они должны программироваться через конфиг. Он динамический и может содержать разные действия, поля и даже сущности в зависимости от ролей пользователя.

Нет внешних шаблонизаторов. В обычных шаблонах в php-файлах важно не забывать экранировать вывод, чтобы не допустить XSS-уязвимости.

Нет современного фронтенда. В библиотеке используется только серверный рендеринг (веб 1.0). Как показывает практика, этого более чем достаточно в непубличных интерфейсах админок.

Нет управления ассетами. Переопределяйте шаблон layout.php и указывайте в нем правильные пути к существующим ассетам.

Нет совместного редактирования. Такие продвинутые фичи уже за скоупом проекта. Если два человека отправят одну и ту же форму, сохранится только последняя версия.

Нет ORM. Вам самим нужно заниматься миграциями, создавать индексы, чтобы не тормозила фильтрация и сортировка, и в некоторых случаях писать фрагменты SQL.

Пример конфигурации

Это раздел для тех, кому интересно посмотреть на программный интерфейс библиотеки.

В простейшем случае в index.php надо поместить следующий код:

<?php

use S2\AdminYard\DefaultAdminFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// Динамический конфиг, рассмотрим чуть ниже
$adminConfig = require 'admin_config.php';

// Типовой код инициализации сервисов AdminYard
// Вместо фабрики можно определить сервисы в каком-нибудь DI контейнере
$pdo = new PDO('mysql:host=localhost;dbname=adminyard', 'username', 'passwd');
$adminPanel = DefaultAdminFactory::createAdminPanel($adminConfig, $pdo, require 'translations/en.php', 'en');

// Нужен компонент Symfony HTTP Foundation
$request = Request::createFromGlobals();
$request->setSession(new Session());
$response = $adminPanel->handleRequest($request);
$response->send();

Конфиг представляет собой php-код в объектном стиле. Вот базовый пример:

<?php

use S2\AdminYard\Config\AdminConfig;
use S2\AdminYard\Config\DbColumnFieldType;
use S2\AdminYard\Config\EntityConfig;
use S2\AdminYard\Config\FieldConfig;
use S2\AdminYard\Config\Filter;
use S2\AdminYard\Config\FilterLinkTo;
use S2\AdminYard\Config\LinkTo;
use S2\AdminYard\Database\PdoDataProvider;
use S2\AdminYard\Event\AfterSaveEvent;
use S2\AdminYard\Event\BeforeDeleteEvent;
use S2\AdminYard\Event\BeforeSaveEvent;
use S2\AdminYard\Validator\NotBlank;
use S2\AdminYard\Validator\Length;

$adminConfig = new AdminConfig();

$commentConfig = new EntityConfig(
    'Comment', // Название сущности в интерфейсе и УРЛах
    'comments' // Название таблицы в БД
);

$postEntity = (new EntityConfig('Post', 'posts'))
    ->addField(new FieldConfig(
        name: 'id', // Название колонки в таблице БД
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Тип - числовой первичный ключ
        // Колонка включена только на экранах list и show.
        // На формах создания и редактирования, очевидно, нельзя редактировать ID сущности.
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW] 
    ))
    ->addField(new FieldConfig(
        name: 'title',
        // Тип колонки - строка. Закомментировано, так как это значение по умолчанию, его можно опустить
        // type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING),
        // Если поле появляется на экранах new или edit, ему надо указать контрол на форме
        control: 'input', // Обычный инпут
        validators: [new Length(max: 80)], // Валидировать максимальную длину поля
        sortable: true, // Разрешить сортировку по этому полю на экране list
        actionOnClick: 'edit' // Сделать ячейку на экране list кликабельной и ведущей на экран edit
    ))
    ->addField(new FieldConfig(
        name: 'text',
        control: 'textarea', // Textarea для текста поста
        // Все экраны за исключением list, так как текст будет распирать таблицу
        useOnActions: [FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT, FieldConfig::ACTION_NEW]
    ))
    ->addField(new FieldConfig(
        name: 'created_at',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_TIMESTAMP), // Тип колонки в БД - timestamp
        control: 'datetime', // HTML5-контрол выбора даты и времени
        sortable: true
    ))
    ->addField(new FieldConfig(
        name: 'comments',
        // Специальный конфиг для связи один-ко-многим. Описывает "виртуальную" колонку, которой нет в БД.
        // Она появится на экранах list и show в виде ссылки на список комментариев с примененным фильтром
        // с подставленным текущим постом.
        type: new LinkedByFieldType(
            $commentConfig, 
            'CASE WHEN COUNT(*) > 0 THEN COUNT(*) ELSE NULL END', // Текст для ссылки
            'post_id'
        ),
        sortable: true
    ))
    // Здесь определяется фильтр на экране list
    ->addFilter(new Filter(
        'search', // Фильтр содержит поле поиска
        'Fulltext Search', // Название поля поиска
        'search_input', // Контрол <input type="search">
        'title LIKE %1$s OR text LIKE %1$s', // шаблон условия для WHERE
        // Функция для преобразования входного значения в параметр для PDO
        // В этом случае пустая строка в поле поиска не приведет к фильтрации,
        // а непустая строка будет искаться как подстрока (LIKE '%...%')
        fn(string $value) => $value !== '' ? '%' . $value . '%' : null
    ))
;

// Fields and filters configuration for "Comment"
$commentConfig
    ->addField(new FieldConfig(
        name: 'id',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT, true), // Primary key
        useOnActions: [] // Скрыть ID со всехе экранов
    ))
    ->addField($postIdField = new FieldConfig(
        name: 'post_id',
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_INT),
        control: 'autocomplete', // Контрол - автодополнение
        validators: [new NotBlank()], // Валидатор для запрета пустых значений поля
        sortable: true,
        // Специальный конфиг для связи многие-к-одному. В колонке будет отображаться ссылка на пост
        // на экранах list и show. Результат "CONCAT('#', id, ' ', title)" будет использован как текст ссылки.
        linkToEntity: new LinkTo($postEntity, "CONCAT('#', id, ' ', title)"),
        // Отключить на экране edit, при редактировании комментария его нельзя перенести к другому посту
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_NEW]
    ))
    ->addField(new FieldConfig(
        name: 'name',
        control: 'input',
        validators: [new NotBlank(), new Length(max: 50)],
        inlineEdit: true, // Разрешить "инлайн-редактирование" поля прямо на экране list
    ))
    ->addField(new FieldConfig(
        name: 'email',
        control: 'email_input', // Контрол <input type="email">
        validators: [new Length(max: 80)],
        inlineEdit: true,
    ))
    // ...
    ->addField(new FieldConfig(
        name: 'status_code',
        // defaultValue используется при добавлении в БД, если поле отсутствует на экране new
        type: new DbColumnFieldType(FieldConfig::DATA_TYPE_STRING, defaultValue: 'new'),
        control: 'radio', // Radio-кнопки для выбора статуса
        options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'],
        inlineEdit: true,
        // Отключить на экране new
        useOnActions: [FieldConfig::ACTION_LIST, FieldConfig::ACTION_SHOW, FieldConfig::ACTION_EDIT]
    ))
    // ...
    ->addFilter(new FilterLinkTo(
        $postIdField, // Специальный фильтр по полю, содержащему связь многие-к-одному
        'Post',
    ))
    ->addFilter(new Filter(
        'created_from', // name контрола в форме фильтров
        'Created after',
        'date',
        'created_at >= %1$s', // Показать комментарии, созданные после указанной даты, created_at - поле коммента
    ))
    ->addFilter(new Filter(
        'created_to', // name контрола в форме фильтров
        'Created before',
        'date',
        'created_at < %1$s', // Показать комментарии, созданные после указанной даты, created_at - поле коммента
    ))
    ->addFilter(new Filter(
        'statuses',
        'Status',
        'checkbox_array', // Несколько чекбоксов можно отметить одновременно для отображения сразу нескольких статусов
        'status_code IN (%1$s)', // Filter comments by status
        options: ['new' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'] // Коды и названия чекбоксов
    ));

// Искусственный пример управления доступом
$postEntity->setReadAccessControl(
    new LogicalExpression('read_access_control', 40, 'id != %1$s AND id != 1 + %1$s')
);
$postEntity->setWriteAccessControl(
    new LogicalExpression('write_access_control', [31, 32, 33, 34, 35], 'id NOT IN (%s)'),
);

// Более полезный пример управления доступом
$postEntity->
    setReadAccessControl(
		isGranted('ROLE_ADMIN')
			? null // админам нет ограничений
			// а не-админы могут просматривать только свои посты или опубликованные чужие
			: new LogicalExpression('expression1', getUserId(), 'published = 1 OR user_id = %s')
	)
	->setWriteAccessControl(
		isGranted('ROLE_ADMIN')
			? null // админам нет ограничений
			// а не-админы могут редактировать только свои посты
			: new LogicalExpression('expression2', getUserId())
	)

return $adminConfig
    ->addEntity($postEntity)
    ->addEntity($commentConfig)
;

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

Системные требования

AdminYard работает в PHP версии 8.2 и выше. Для работы нужна реляционная база данных: MySQL, PostgreSQL или SQLite.

Кому подойдет?

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

Новые проекты, особенно объемом больше одного человеко-месяца, я бы не стал делать на AdminYard с нуля. Вам всё равно понадобится какой-нибудь фреймворк и библиотеки, лучше взять готовую админку, интегрирующуюся с ними.

Впечатления от разработки

Я взялся за разработку AdminYard, во многом надеясь на помощь нейросетей. Конечно, я бы и сам мог написать весь код с нуля, но у меня бы ушло слишком много времени на рутину и не осталось бы энтузиазма для размышлений над действительно интересными и творческими моментами. Да и самого времени, как всегда, не хватает.

Сначала я попросил ChatGPT предложить пример описания конфигурации админки на PHP в объектном стиле. Отредактировал и доработал этот пример. Затем попросил создать классы для описания конфигурации, сервисы, фикстуры демо-приложения. Первый результат, который хоть как-то выводил настоящее содержимое базы данных, появился достаточно быстро. Это придало мне уверенности в том, что я смогу за разумное время довести проект до состояния, в котором его можно применять в существующих проектах.

По ходу разработки меняется характер работы: программист переходит от крупных мазков и постоянного создания новых классов к работе над деталями, доработкам и рефакторингу. Здесь уже ChatGPT не помогает, так как сформулировать задачу для него слишком сложно из-за отсутствия контекста. Мне на помощь пришла нейросеть Codeium. Я установил ее как плагин к PhpStorm и использовал подсказки с автодополнением кода. Конечно, они не снимают необходимость понимать, что происходит в коде, но определенную помощь тоже оказывают.

Планы на будущее

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

Ссылки

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


  1. Vadiok
    16.12.2024 18:01

    Думаю, что большинство PHP легаси проектов не поддерживают PHP 8.2.


    1. parpalak Автор
      16.12.2024 18:01

      Если проект приходится поддерживать и дорабатывать, всё равно нужно рано или поздно обновлять версию PHP. Практика показывает, что это делать не так сложно, по сравнению с обновлением зависимостей и тем более переработкой кода. Нужно исправить ворнинги, депрекейшены и прочие ошибки, о которых сообщает сам интерпретатор. Код переписывать не нужно.


      1. DmitryVoronkov
        16.12.2024 18:01

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


        1. parpalak Автор
          16.12.2024 18:01

          На прошлой работе я начинал новый проект с нуля еще на PHP 7.1. Он дорос до более чем 100 000 строк кода в папке src/ и спокойно переживал обновления версии PHP раз в год-два. Это проходило существенно легче, чем, скажем, обновление мажорной версии Symfony. Кстати, коллега подключал и использовал инструмент Rector для автоматического исправления несовместимостей в коде, может кому-нибудь пригодится: https://github.com/rectorphp/rector

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


          1. DmitryVoronkov
            16.12.2024 18:01

            Так 7.1 это далеко от слова легаси) я про настоящем легаси с версии php5, где есть какие-нибудь либы для memcahe или mongo которые работают состарым драйвером. Это все переписывать очень не просто)

            Ну а привлекательность разработчиков можно купить) это может оказатся дешевле человеко-часов на переписывание или еще хуже падения прода


            1. alexalexes
              16.12.2024 18:01

              mysql -> mysqli. Вот где жесть при перелопачивании PHP легаси. Нужно добавлять контекст соединений, и нормально делать prepare к запросам, а не то, как умели на коленке.


              1. FanatPHP
                16.12.2024 18:01

                Ну, справедливости ради, если чтобы "лишь бы работало", то один инклюд. Но по-хорошему-то да, все запросы на execute_query() переписывать.


        1. serg52
          16.12.2024 18:01

          Очень сложный вопрос.

          Разбора инцидента на habr конечно не будет, но гас правосудие недавно знатно упал на пару месяцев (злые языки говорили что не поднимут)


      1. den_rad
        16.12.2024 18:01

        Есть такой страшный код или старые зависимости, которые просто так не обновишь. Например, у меня есть проект, написанный в 2008, который использует старую библиотеку работы с БД, которая <= php 5.5.
        Проще проект переписать заново, чем обновлять в нем зависимости.


    1. den_rad
      16.12.2024 18:01

      Можно отдельно запустить PHP 8.2 в контейнере, сейчас это несложно.


      1. Vadiok
        16.12.2024 18:01

        Если запускать отдельный контейнер, то тогда уже можно любую админку запилить, например Laravel + Filament.


    1. Batorskylab
      16.12.2024 18:01

      аха-ха, у меня 5.1)


  1. FanatPHP
    16.12.2024 18:01

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

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

    То же самое, кстати, касается и имён плейсхолдеров, только там фиксить гораздо сложнее. При том что в целом при автоматическом составлении запросов использовать именованные плейсхолдеры смысла ноль.

    Многое также становится на свои места. Я долго не мог понять, почему метод deleteEntity делает аж три запроса в БД.


    1. parpalak Автор
      16.12.2024 18:01

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

      Метод escapeIdentifier в общем случае, видимо, нужен, запишу в бэклог. В моем случае с названиями таблиц и полей проблем не было.

      Метод deleteEntity делает три запроса, чтобы понять, произошло ли реально удаление строки из БД. Мне захотелось сделать фичу с выводом сообщения об успешном удалении или об ошибке в процессе. Теоретически можно было бы использовать PDOStatement::rowCount(), но в документации явно сказано, что rowCount не работает в SQLite и не всегда работает в PostgreSQL. Вы можете предложить хотя бы один сценарий удаления сущностей из админки, когда три запроса вместо одного хоть как-то чему-то мешают?


  1. AnthonyAxenov
    16.12.2024 18:01

    AdminYard — минимальная админка на PHP для легаси-проектов
    ...
    AdminYard работает в PHP версии 8.2 и выше.

    Это же просто смешно.


    1. prishelec
      16.12.2024 18:01

      Тише … Не расстраивайте автора.


  1. Mingun
    16.12.2024 18:01

    Я, кстати, не уверен, что в легаси-проектах вообще может быть разделение на все эти сущности «Запись блога», «Комментарий» и прочие, которыми так удобно любят оперировать при представлении админок подобного толка. Зачастую «сущностью» в базе служит сотня строк в этой таблице и ещё тысяча-другая строк в соседней, редактирование которых по отдельности абсолютно бессмысленно. А потому проще и продуктивнее править это всё через нормальный клиент к БД, sqldeveloper там или DBeaver.


  1. mnv
    16.12.2024 18:01

    На днях этот же плагин опробовал. С ним у меня получается сгенерировать небольшие полезные кусочки кода по 5-10 строк. Это конечно тоже довольно хорошо. Но вот не получилось пока добиться чтобы этот интеллект создавал файлили по аналогии с другими, но для новых сущностей. Интересно, есть что-то для таких запросов?


    1. parpalak Автор
      16.12.2024 18:01

      Я было подумал, что ты AdminYard на днях опробовал :)

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

      Могу попробовать предложить временно копировать в новую сущность существующий код, чтобы нейросеть смогла подхватить паттерны из него. Подобным образом можно конвертировать, скажем, XML-конфиг в JSON. Прямо в начале xml-файла можно начать переписывать его содержимое в синтаксисе json, а автодополнение распознает и доделает эту задачу.