Всем доброго времени суток!
Нынче многие бросают свой взгляд в сторону EasyAdminBundle, но я до сих пор использую и предпочитаю одну из лучших панелей администратора для Symfony.
SonataAdminBundle
Система очень гибкая и многими недооценённая, мол Sonata ограничивает администратора в действиях (представляет малый функционал панели администратора). Если Вам нужно что-то иное, всегда можно дополнить или модернизировать уже существующие методы.

Недавно мне нужно было произвести пакетное действие для продуктов и после выполненной работы захотелось рассказать об этом Вам. Вдобавок прикреплю Select2, ибо у меня возникали некие трудности, офф.документация ограничена в информации.
Итак, начнем сразу с чистого проекта. На момент создания хаба актуальные версии пакетов:
symfony/skeleton 6.1
При установке никаких проблем особо возникать не должно, в ином случае в интернете полно информации об установке SonataAdminBundle.
Для демонстративной работы пакетного действия нам понадобится 2 сущности, со связью M2M.
Создаём сущность Product:
#src/Entity/Product.php <?php namespace App\Entity; use App\Repository\ProductRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ProductRepository::class)] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] private ?string $name = null; #[ORM\Column(nullable: true)] private ?int $price = null; #[ORM\ManyToMany(targetEntity: Category::class, mappedBy: 'products')] private Collection $categories; public function __construct() { $this->products = new ArrayCollection(); } public function __toString(): string { return $this->name; } //getters and setters }
Также создаём Category:
#src/Entity/Category.php <?php namespace App\Entity; use App\Repository\CategoryRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CategoryRepository::class)] class Category { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] private ?string $name = null; #[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'categories')] private Collection $products; public function __construct() { $this->products = new ArrayCollection(); } public function __toString(): string { return $this->name; } //getters and setters }
В обе сущности требуется добавить метод возврата строки __toString
Обновим таблицы в БД:
bin/console doctrine:schema:update --force
Теперь создадим файлы для панели администратора:
#src/Admin/ProductAdmin.php <?php namespace App\Admin; use Sonata\AdminBundle\Admin\AbstractAdmin; use Sonata\AdminBundle\Datagrid\DatagridMapper; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Form\FormMapper; use Sonata\AdminBundle\Show\ShowMapper; class ProductAdmin extends AbstractAdmin { protected function configureFormFields(FormMapper $form): void { if ($this->getSubject()->getId()) { $form ->add('price') ->add('categories') ; }else { $form ->add('name') ->add('price') ->add('categories') ; } } protected function configureDatagridFilters(DatagridMapper $filter): void { $filter ->add('name') ->add('price') ->add('categories') ; } protected function configureListFields(ListMapper $list): void { $list ->addIdentifier('name') ->add('price') ->add('categories') ->add('_action', 'actions',[ 'actions' => [ 'edit' => [], 'delete' => [], ] ]) ; } protected function configureShowFields(ShowMapper $show): void { $show ->with('Product') ->add('name') ->add('price') ->end() ->with('Categories') ->add('categories') ->end() ; } }
#src/Admin/CategoryAdmin.php <?php namespace App\Admin; use Sonata\AdminBundle\Admin\AbstractAdmin; use Sonata\AdminBundle\Datagrid\DatagridMapper; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\AdminBundle\Form\FormMapper; use Sonata\AdminBundle\Show\ShowMapper; class CategoryAdmin extends AbstractAdmin { protected function configureFormFields(FormMapper $form): void { $form ->add('name') ->add('products') ; } protected function configureDatagridFilters(DatagridMapper $filter): void { $filter ->add('name') ->add('products') ; } protected function configureListFields(ListMapper $list): void { $list ->addIdentifier('name') ->add('products') ; } protected function configureShowFields(ShowMapper $show): void { $show ->with('Category') ->add('name') ->end() ->with('Products') ->add('products') ->end() ; } }
Нужно их зарегистрировать:
#config/services.yaml services: App\Admin\ProductAdmin: arguments: [ ~, App\Entity\Product, ~ ] tags: - { name: sonata.admin, manager_type: orm, group: Content, label: Product } App\Admin\CategoryAdmin: arguments: [ ~, App\Entity\Category, ~ ] tags: - { name: sonata.admin, manager_type: orm, group: Content, label: Category }
Добавим кастомное поле, где будет отображаться скидка и цена со скидкой.
Саму скидку запишем в параметры контейнера:
#config/services.yaml parameters: discount: 15
Теперь передадим этот параметр в качестве аргумента:
#config/services.yaml services: App\Admin\ProductAdmin: arguments: [ ~, App\Entity\Product, ~, '%discount%' ]
Принимаем параметр и создаём само поле:
#src/Admin/ProductAdmin.php <?php private ?int $discount; public function __construct( ?string $code = null, ?string $class = null, ?string $baseControllerName = null, ?int $discount = null ) { parent::__construct($code, $class, $baseControllerName); $this->discount = $discount; } protected function configureListFields(ListMapper $list): void { $list ->add('discountPrice', null,[ 'template' => 'SonataAdmin/price.html.twig', 'discount' => $this->discount, ]) ; }
Чтобы не вызывало ошибку о несуществующем методе, создадим метод в сущности продукта. Не важно что он будет возвращать.
#src/Entity/Product.php <?php public function getDiscountPrice(): int { return 1; }
Нам теперь нужно создать шаблон, который будет отображать поле
#templates/SonataAdmin/price.html.twig {% extends '@SonataAdmin/CRUD/base_list_field.html.twig' %} {% block field %} DiscountPrice: {{ (object.price/100*(100-field_description.options.discount))|round }}<br> Discount: {{ field_description.options.discount }} {% endblock %}
Добавим тройку тестовых записей продуктов, заодно сразу тройку категорий.
Проверяем.

Можно создавать пакетное действие. Нужно создать метод configureBatchActions
#src/Admin/ProductAdmin.php <?php protected function configureBatchActions(array $actions): array { $actions['add_category'] = [ 'ask_confirmation' => true, //можно убрать, если не нужно подтверждение действия ]; return $actions; }
Проверим отображение

Попробуем добавить поле выбора категории. Для этого я решил выбрать Select2 с подгрузкой названий с помощью Ajax. Создадим новый шаблон. Метод пакетного удаления мне не нужен, поэтому я удалил возможность выбора и всегда будет использоваться только наш метод:
#templates/SonataAdmin/list.html.twig {% extends '@SonataAdmin/CRUD/base_list.html.twig' %} {% block batch_actions %} <label class="checkbox" for="{{ admin.uniqid }}_all_elements"> <input type="checkbox" name="all_elements" id="{{ admin.uniqid }}_all_elements"> {{ 'all_elements'|trans({}, 'SonataAdminBundle') }} ({{ admin.datagrid.pager.countResults() }}) </label> <input id="action" name="action" value="addCategory" style="display: none"> <select id="category" name="category" style="width: 150px"></select> <script> $(function (){ $('#category').select2({ ajax:{ url: '/find_category_ajax', dataType: 'json', processResults: function (data) { return { results: data }; }, }, minimumInputLength: 3, }) }) </script> {% endblock %}
Установим новый шаблон для нашего списка
#config/services.yaml services: App\Admin\ProductAdmin: calls: - [ setTemplate, [ list, SonataAdmin/list.html.twig ] ]

Теперь у нас поле выбора категории, но ещё не настроен ответ Ajax.
Категории мы уже создали.
Нужно создать запрос query в CategoryRepository, который будет возвращать сущности с похожим набором символов в имени.
#src/Repository/CategoryRepository.php <?php namespace App\Repository; use App\Entity\Category; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** * @extends ServiceEntityRepository<Category> * * @method Category|null find($id, $lockMode = null, $lockVersion = null) * @method Category|null findOneBy(array $criteria, array $orderBy = null) * @method Category[] findAll() * @method Category[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class CategoryRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Category::class); } public function add(Category $entity, bool $flush = false): void { $this->getEntityManager()->persist($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function remove(Category $entity, bool $flush = false): void { $this->getEntityManager()->remove($entity); if ($flush) { $this->getEntityManager()->flush(); } } public function findToName(string $value) { return $this->createQueryBuilder('c') ->andWhere('LOWER(c.name) LIKE :val') ->setParameter('val', '%'.$value.'%') ->setMaxResults(5) ->getQuery() ->getResult() ; } }
Создадим контроллер, который будет принимать запрос от Ajax.
#src/Controller/Admin/CategoryAjaxController.php <?php namespace App\Controller\Admin; use App\Entity\Category; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class CategoryAjaxController extends AbstractController { public function findCategoryAjax(ManagerRegistry $doctrine, Request $request): Response { $string = $request->query->get('q'); $categories = $doctrine->getRepository(Category::class)->findToName($string); $result = array(); foreach ($categories as $category) { $result[] = [ 'id' => $category->getId(), 'text' => $category->getName(), ]; } return new Response(json_encode($result)); } }
Настроим маршрут для контроллера
#config/routes.yaml find_category_ajax: path: /find_category_ajax controller: App\Controller\Admin\CategoryAjaxController::findCategoryAjax
Теперь можно проверить работу нашего Select2

Осталось только настроить контроллер, который будет принимать категорию и добавлять её к выделенным продуктам.
Нам нужен CRUD контроллер и метод с названием нашего пакетного действия:
#src/Controller/Admin/ProductAdminController.php <?php namespace App\Controller\Admin; use App\Entity\Category; use Sonata\AdminBundle\Controller\CRUDController; use Sonata\AdminBundle\Admin\AdminInterface; use Sonata\AdminBundle\Datagrid\ProxyQueryInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Doctrine\Persistence\ManagerRegistry; class ProductAdminController extends CRUDController { private ManagerRegistry $doctrine; public function __construct(ManagerRegistry $doctrine) { $this->doctrine = $doctrine; } public function batchActionAddCategory( ProxyQueryInterface $query, AdminInterface $admin, ): RedirectResponse { $id = json_decode($_POST['data'])->category; $category = $this->doctrine->getRepository(Category::class)->find($id); $entity = $this->doctrine->getManager(); $products = $query->execute(); foreach ($products as $product) { $product->addCategory($category); $entity->persist($product); } $entity->persist($category); $entity->flush(); $this->addFlash( 'sonata_flash_success', 'Successfully added to "'.$category->getName().'" category' ); return new RedirectResponse( $admin->generateUrl('list',[ 'filter' => $admin->getFilterParameters() ]) ); } }
Теперь передадим в наш сервис контроллер в качестве аргумента:
#config/services.yaml services: App\Admin\ProductAdmin: arguments: [ ~, App\Entity\Product, App\Controller\Admin\ProductAdminController, '%discount%']
Попробуем добавить категорию к нескольким продуктам

Как мы видим, теперь всё работает и работает всё как нам нужно и к отмеченным продуктам прикрепилась выбранная категория.
Заключение
Я хотел вам показать, что SonataAdminBundle не ограничивает Вас в действиях. Вы можете сделать любое действие, которое Вам требуется, нужно лишь знать как и где это править.
Буду рад любым замечаниям и комментариям, также постараюсь ответить на вопросы, если у Вас они возникнут.
