Ми часто стикаємося з ситуацією: один і той самий функціонал (відгуки, рейтинги, кастомні сторінки) потрібен на трьох-чотирьох сайтах. Копіювати контролери та шаблони з проєкту в проєкт — шлях до хаосу. На одному з проєктів — мережа інтернет-магазинів — ми розробили 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. Отримайте консультацію по вашому проєкту — зв'яжіться з нами.







