Мы часто сталкиваемся с ситуацией: один и тот же функционал (отзывы, рейтинги, кастомные страницы) нужен на трёх-четырёх сайтах. Копировать контроллеры и шаблоны из проекта в проект — путь к хаосу. На одном из проектов — сеть интернет-магазинов — мы разработали ReviewBundle для Sulu CMS. Результат: обновление функционала занимает 10 минут вместо 8 часов, а баг-фиксы сократились в 3 раза. Решение — вынести логику в изолированный Symfony Bundle, который подключается через Composer. Ниже на примере ReviewBundle разберём архитектуру, процесс разработки и типичные ошибки. Если у вас есть схожая задача, получите консультацию — свяжитесь с нами.
Какие проблемы решает Bundle?
- Дублирование кода. Одна правка в логике модерации — и нужно менять три копии. Ошибки при копировании неизбежны. Bundle устраняет дублирование: исправляете в одном месте — обновляете во всех проектах через Composer.
- Сложность версионирования. Когда функционал живёт в монолите, нельзя легко откатить его версию. В Bundle используем Git-теги и composer.json, управляем зависимостями централизованно.
- Отсутствие готового backoffice. Без кастомного Bundle каждую сущность приходится администрировать через прямые SQL-запросы или самописные скрипты. Мы создаём Admin-класс с навигацией, списком и формой редактирования — прямо как родные разделы Sulu.
Почему Bundle выгоднее копирования?
Изолированный Bundle даёт три ключевых преимущества. Во-первых, версионирование: вы выпускаете релизы через Composer, а не вспоминаете, в каких проектах скопировали файлы. Во-вторых, тестируемость: пишете юнит-тесты один раз и прогоняете при каждом обновлении. В-третьих, читаемость: новый разработчик видит чёткую структуру папок и знает, что всё относящееся к отзывам — в одном месте. На практике мы наблюдали сокращение времени на баг-фиксы в 2–3 раза после миграции с копий на Bundle. Экономия на поддержке может достигать 40 часов в месяц.
| Аспект | Копирование кода | Bundle |
|---|---|---|
| Время на обновление | 8 часов | 10 минут |
| Риск ошибок при копировании | Высокий | Нет |
| Версионирование | Отсутствует | Git-теги |
| Тестирование | Требует копирования тестов | Одна кодовая база |
Архитектура Bundle: ключевые компоненты
Структура папок ReviewBundle — разработка кастомного sulu
src/
└── ReviewBundle/
├── Admin/
│ └── ReviewAdmin.php
├── Controller/
│ ├── Admin/
│ │ └── ReviewController.php
│ └── Website/
│ └── ReviewWidgetController.php
├── DependencyInjection/
│ ├── ReviewExtension.php
│ └── Configuration.php
├── Document/
├── Entity/
│ └── Review.php
├── Repository/
│ └── ReviewRepository.php
├── Resources/
│ ├── config/
│ │ ├── doctrine/
│ │ │ └── Review.orm.xml
│ │ ├── routes_admin.yaml
│ │ └── services.xml
│ └── js/
│ ├── index.js
│ ├── views/
│ └── containers/
├── ReviewBundle.php
└── composer.json
Extension и конфигурация
// DependencyInjection/ReviewExtension.php
namespace App\ReviewBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class ReviewExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('review.per_page', $config['per_page']);
$container->setParameter('review.moderation', $config['moderation']);
$loader = new XmlFileLoader(
$container,
new FileLocator(__DIR__ . '/../Resources/config')
);
$loader->load('services.xml');
}
}
Admin-класс и backoffice
// Admin/ReviewAdmin.php
namespace App\ReviewBundle\Admin;
use Sulu\Bundle\AdminBundle\Admin\Admin;
use Sulu\Bundle\AdminBundle\Admin\Navigation\NavigationItem;
use Sulu\Bundle\AdminBundle\Admin\Navigation\NavigationItemCollection;
use Sulu\Bundle\AdminBundle\Admin\View\ToolbarAction;
use Sulu\Bundle\AdminBundle\Admin\View\ViewBuilderFactoryInterface;
use Sulu\Bundle\AdminBundle\Admin\View\ViewCollection;
use Sulu\Component\Security\Authorization\PermissionTypes;
use Sulu\Component\Security\Authorization\SecurityCheckerInterface;
class ReviewAdmin extends Admin
{
const REVIEW_LIST_VIEW = 'review.list';
const REVIEW_EDIT_VIEW = 'review.edit_form';
const SECURITY_CONTEXT = 'sulu.review.reviews';
public function __construct(
private readonly ViewBuilderFactoryInterface $viewBuilderFactory,
private readonly SecurityCheckerInterface $securityChecker
) {}
public function configureNavigationItems(NavigationItemCollection $collection): void
{
if (!$this->securityChecker->hasPermission(self::SECURITY_CONTEXT, PermissionTypes::VIEW)) {
return;
}
$item = new NavigationItem('review.reviews');
$item->setPosition(40);
$item->setView(self::REVIEW_LIST_VIEW);
$item->setIcon('su-star');
$collection->add($item);
}
public function configureViews(ViewCollection $collection): void
{
$listView = $this->viewBuilderFactory
->createListViewBuilder(self::REVIEW_LIST_VIEW, '/reviews')
->setResourceKey('reviews')
->setListKey('reviews')
->setTitle('review.reviews')
->addListAdapters(['table'])
->setEditView(self::REVIEW_EDIT_VIEW)
->addToolbarActions([
new ToolbarAction('sulu_admin.add'),
new ToolbarAction('sulu_admin.delete'),
]);
$editView = $this->viewBuilderFactory
->createResourceTabViewBuilder(self::REVIEW_EDIT_VIEW, '/reviews/:id')
->setResourceKey('reviews')
->setBackView(self::REVIEW_LIST_VIEW);
$collection->add($listView);
$collection->add($editView);
}
public function getSecurityContexts(): array
{
return [
self::SECURITY_CONTEXT => [
PermissionTypes::VIEW,
PermissionTypes::ADD,
PermissionTypes::EDIT,
PermissionTypes::DELETE,
],
];
}
}
REST API
// Controller/Admin/ReviewController.php
namespace App\ReviewBundle\Controller\Admin;
use App\ReviewBundle\Repository\ReviewRepository;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sulu\Component\Rest\ListBuilder\Doctrine\DoctrineListBuilderFactoryInterface;
use Sulu\Component\Rest\ListBuilder\Metadata\FieldDescriptorFactoryInterface;
use Sulu\Component\Rest\RestHelperInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class ReviewController extends AbstractFOSRestController
{
public function __construct(
ViewHandlerInterface $viewHandler,
private readonly ReviewRepository $repository,
private readonly RestHelperInterface $restHelper,
private readonly FieldDescriptorFactoryInterface $fieldDescriptorFactory,
private readonly DoctrineListBuilderFactoryInterface $listBuilderFactory
) {
parent::__construct($viewHandler);
}
#[Route('/api/reviews', methods: ['GET'])]
public function cgetAction(Request $request): Response
{
$fieldDescriptors = $this->fieldDescriptorFactory->getFieldDescriptors('reviews');
$listBuilder = $this->listBuilderFactory->create(Review::class);
$this->restHelper->initializeListBuilder($listBuilder, $fieldDescriptors);
$list = new ListRepresentation(
$listBuilder->execute(),
'reviews',
'review_api_review_cget',
$request->query->all(),
$listBuilder->getCurrentPage(),
$listBuilder->getLimit(),
$listBuilder->count()
);
return $this->handleView($this->view($list));
}
#[Route('/api/reviews', methods: ['POST'])]
public function postAction(Request $request): Response
{
$data = $request->toArray();
$review = $this->repository->createFromArray($data);
$this->repository->save($review, true);
return $this->handleView($this->view($review, 201));
}
#[Route('/api/reviews/{id}', methods: ['DELETE'])]
public function deleteAction(int $id): Response
{
$this->repository->removeById($id);
return $this->handleView($this->view(null, 204));
}
}
Что входит в разработку?
- Полный анализ требований и документ с описанием сущностей, API и связей.
- Создание структуры Bundle, регистрация Admin-классов, типов свойств, REST API.
- Doctrine-миграции и конфигурация Symfony (services.xml, routes).
- Кастомные backoffice view (React/Preact) при необходимости.
- Комплексное тестирование: юнит-тесты, функциональные тесты, проверка интеграции с Sulu.
- Документация: README, руководство по обновлению, описание конфигурации.
- Гарантийная поддержка 2 недели после сдачи.
Процесс работы и сроки
- Анализ и прототип — определяем сущности, поля, связи, API-эндпоинты. Создаём заготовку Bundle. Срок: 1–2 дня.
- Проектирование — проектируем Admin-классы, ContentType, миграции. Согласовываем с вами UX backoffice. Срок: 1 день.
- Разработка — реализуем все компоненты, пишем код и тесты. Срок: 3–10 дней.
- Интеграция — установка и настройка в вашем проекте. Срок: 1 день.
- Документация — README, инструкция по обновлению. Срок: 0.5 дня.
| Этап | Результат | Примерный срок |
|---|---|---|
| Анализ и прототип | Документ с описанием сущностей и API | 1–2 дня |
| Проектирование | Схема Bundle, шаблоны Admin-views | 1 день |
| Разработка | Полный код Bundle с тестами | 3–10 дней |
| Интеграция | Установка и настройка в вашем проекте | 1 день |
| Документация | README, инструкция по обновлению | 0.5 дня |
| Гарантийная поддержка | 2 недели после сдачи | — |
Какие ошибки чаще всего допускают при разработке Bundle?
- Забыли зарегистрировать сервис в services.xml — без тега
sulu.adminAdmin-класс не появится в навигации. - Неправильный namespace — Symfony не найдёт класс, если namespace не совпадает с путём.
- Не подключили миграции Doctrine — сущности не создадутся в БД. Добавьте Bundle в
config/packages/doctrine.yaml. - Не настроили права доступа — пользователи не увидят раздел. Реализуйте
getSecurityContexts()как в примере выше.
Сроки и стоимость
Базовый Bundle с Doctrine-сущностью, REST API и регистрацией в backoffice (без кастомного фронтенда) — 5–7 дней. С кастомным фронтендом backoffice (React/Preact компоненты), миграциями и кастомным типом свойства — 2–3 недели. Точную оценку даём после анализа вашей задачи. Оставьте заявку на сайте, и мы свяжемся с вами в течение дня.
Мы разрабатываем кастомные Sulu Bundle более 5 лет, за плечами 30+ успешных проектов. Гарантируем стабильность интеграции и соблюдение код-стайла Symfony. Получите консультацию по вашему проекту — свяжитесь с нами.







