29 KiB
Backend: CRUD для контентных сущностей
Эта страница объясняет рефакторинг CRUD-эндпоинтов для контентных сущностей backend API. Цель изменений - чтобы backend-разработчик быстро понял, где находится код, как проходит запрос, как добавить новый похожий ресурс и почему пагинация сделана через Pagerfanta.
Задача (#27)
Нужно дать API для админ-панели контент-менеджеров (UI), чтобы управлять контентом не напрямую через Bitrix, а через backend:
- новости;
- акции;
- заболевания;
- центры;
- статьи;
- услуги.
Основные требования:
- единый CRUD-подход для всех шести ресурсов;
- пагинация в стиле Symfony/проекта, без ручного
LIMIT/OFFSET + COUNT; - минимум дублирования в контроллерах и сервисах;
- запись только для пользователей с
ROLE_ADMIN; - сохранить существующую синхронизацию из SQL view/Bitrix.
Ресурсы и маршруты
| Сущность | Контроллер | Базовый путь | Read group | Write group |
|---|---|---|---|---|
News |
NewsController |
/news |
news:read |
news:write |
Promo |
PromoController |
/promo |
promo:read |
promo:write |
Disease |
DiseaseController |
/disease |
disease:read |
disease:write |
MedicalCenter |
MedicalCenterController |
/medical-center |
medical_center:read |
medical_center:write |
Article |
ArticleController |
/article |
article:read |
article:write |
SiteService |
SiteServiceController |
/site-services |
site_service:read |
site_service:write |
У всех ресурсов есть одинаковый набор CRUD-маршрутов:
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
GET |
/{resource}/list |
публичный | список с пагинацией и фильтрами |
GET |
/{resource}/{id} |
публичный | детальная карточка |
POST |
/{resource}/create |
ROLE_ADMIN |
создание |
PUT |
/{resource}/{id} |
ROLE_ADMIN |
обновление |
DELETE |
/{resource}/{id} |
ROLE_ADMIN |
удаление |
У ArticleController дополнительно есть GET /article/alias/{alias}.
Общая схема
flowchart TD
Client[HTTP client / admin panel] --> Controller[Content Controller]
Controller -->|GET /list| Repository[Repository::createFilteredQueryBuilder]
Repository --> Filter[ContentFilterTrait]
Filter --> QueryBuilder[Doctrine QueryBuilder]
QueryBuilder --> Paginator[Pagination\\Paginator]
Paginator --> Pagerfanta[Pagerfanta + QueryAdapter]
Pagerfanta --> JsonList[JSON: data + pagination/meta]
Controller -->|POST/PUT/DELETE/GET detail| CrudResponder[CrudResponder]
CrudResponder --> Serializer[Symfony Serializer groups]
CrudResponder --> Validator[Symfony Validator]
CrudResponder --> EntityManager[Doctrine EntityManager]
EntityManager --> Db[(PostgreSQL)]
Commands[Upload*Command] --> SyncService[*CrudService::syncFromView*]
SyncService --> Db
Ключевой принцип
Контроллер не должен знать, как парсить JSON, как делать persist/flush, как форматировать ошибки валидации и как считать страницы.
Контроллер отвечает только за HTTP-маршрут, выбор entity-класса, serializer groups и вызов общего сервиса.
Тонкий контроллер
Пример NewsController:
#[Route('/news')]
final class NewsController extends AbstractController
{
private const READ_GROUPS = ['news:read'];
private const WRITE_GROUPS = ['news:write'];
public function __construct(
private readonly CrudResponder $crud,
private readonly Paginator $paginator,
) {
}
#[Route('/list', name: 'news_list', methods: ['GET'])]
public function list(Request $request, NewsRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder(
ContentFilterDto::fromRequest($request, defaultActive: true),
);
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
]);
}
#[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\\d+'])]
public function show(News $news): JsonResponse
{
return $this->crud->read($news, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/create', name: 'news_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\\d+'])]
public function update(Request $request, News $news): JsonResponse
{
return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\\d+'])]
public function delete(News $news): JsonResponse
{
return $this->crud->delete($news);
}
}
Остальные пять контроллеров устроены так же. Отличаются только:
- базовый
#[Route]; - entity-класс;
- repository-класс;
- serializer groups.
Пагинация
Пагинация вынесена в App\Service\Pagination\Paginator.
Причина: в проекте уже есть похожий подход в PriceListController: QueryAdapter + Pagerfanta. Поэтому для новых списков не нужно писать свою пагинацию и отдельный COUNT-запрос.
Сервис принимает готовый Doctrine QueryBuilder и Request:
public function paginate(
QueryBuilder $qb,
Request $request,
int $defaultPerPage = self::DEFAULT_PER_PAGE,
int $maxPerPage = self::MAX_PER_PAGE,
): array
Он читает:
page- номер страницы, минимум1;perPage- размер страницы, минимум1, максимум500.
Формат ответа для news, promo, disease, medical-center, site-services:
{
"data": [],
"pagination": {
"total": 42,
"count": 10,
"per_page": 10,
"current_page": 1,
"total_pages": 5,
"has_previous_page": false,
"has_next_page": true
}
}
Для article сохранён старый API-контракт фронтенда: параметр размера страницы называется limit, а метаданные лежат в ключе meta.
{
"data": [],
"meta": {
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}
Это сделано через отдельный метод Paginator::paginateWithLegacyMeta(), чтобы не ломать клиентов, которые читают response.data.meta.total.
Важно: Paginator не знает ничего про конкретную сущность. Он работает с любым QueryBuilder.
Фильтрация списков
Каждый repository имеет метод:
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
Пример NewsRepository:
class NewsRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
$this->applyCommonFilters($qb, 'n', $filters);
return $qb;
}
}
Общие фильтры лежат в App\Repository\ContentFilterTrait. Он подключается в нужные Doctrine-репозитории и не требует статических helper-вызовов.
HTTP query-параметры не передаются в repository сырым массивом. Контроллер сначала мапит Request в ContentFilterDto:
// По умолчанию без фильтра по active (как /disease, /article):
ContentFilterDto::fromRequest($request)
// Легаси: если параметр active не передан, считать active = true (news, promo, medical-center, site-services):
ContentFilterDto::fromRequest($request, defaultActive: true)
Так слой БД получает типизированный объект с уже разобранными значениями (?int, ?bool, ?string), а не array<string, mixed> из HTTP.
Нежелательные формы query вроде ?regionId[]=1 или ?active[]=1 отдают Symfony в get() как массив: ContentFilterDto обрабатывает только scalar-значения для числовых и булевых полей, такие случаи трактуются как «фильтр не задан» и не вызывают 500 (TypeError).
Поддерживаемые query-параметры:
| Параметр | Тип | Что делает |
|---|---|---|
regionId / region_id |
integer | фильтр по региону |
active |
boolean | фильтр активности; для /news, /promo, /medical-center, /site-services/list при отсутствии параметра применяется active = true (легаси). Для /disease и /article без параметра фильтр по active не накладывается |
alias |
string | точное совпадение alias |
search / q |
string | поиск по LOWER(name) LIKE ... |
Для больших таблиц под search нужен функциональный индекс PostgreSQL по LOWER(name). Если решите заменить это на ILIKE, потребуется отдельная Doctrine DQL-функция или переход на native SQL для этого фильтра.
Поле, по которому ищем, параметризовано в трейте: applyCommonFilters($qb, $alias, $filters, $searchField = 'name'). Все шесть текущих сущностей имеют свойство $name, поэтому в репозиториях оно передаётся неявно по умолчанию. Если новая сущность хранит основное название в другом поле (например, title), достаточно явно прокинуть его именем в трейт.
Naming strategy
В config/packages/serializer.yaml сознательно не настроен NameConverter. JSON-ключи запросов и ответов используют camelCase ровно так, как названы свойства сущности (regionId, previewPicture, updateAt, …). Если клиент пришлёт snake_case (region_id, preview_picture), Symfony Serializer молча проигнорирует такие ключи, и поля не запишутся в сущность - это и есть причина, по которой старые *CrudService поддерживали оба формата вручную через array_key_exists. Теперь поддержка обоих форматов сознательно убрана: клиент должен присылать консистентный camelCase, иначе будет тихая потеря данных.
Пример запроса:
curl 'http://localhost:8081/news/list?page=1&perPage=20®ionId=91&active=true&search=акция'
Create / Update / Delete
Общая логика записи вынесена в App\Service\Crud\CrudResponder.
Что делает CrudResponder:
- проверяет, что body - JSON-объект (ловит и нативный
\JsonException, иSymfony\...\HttpFoundation\Exception\JsonException); - денормализует payload через
DenormalizerInterface::denormalize($array, $class, null, [...]), без дополнительногоjson_encode/deserializeround-trip; - использует write-группу сущности, например
news:write; - удаляет
idиз payload перед денормализацией для create/update; - валидирует entity через Symfony Validator и при ошибке отдаёт HTTP 400 + сериализованный
ConstraintViolationList(формат Symfony по умолчанию, RFC 7807 с ключомviolations) - тот же формат, что отдавали старые*CrudService-контроллеры; - делает
persist/flushдля create; - делает
flushдля update; - делает
remove/flushдля delete; при ошибке БД (например, foreign key constraint) ловитDoctrine\DBAL\Exceptionи возвращаетHTTP 500+{error, message}- сохраняем легаси-контракт старогоArticleController::delete; - сериализует ответ через read-группу, например
news:read.
Контракты ответов на ошибки
| Сценарий | HTTP | Тело |
|---|---|---|
| Тело не JSON-объект | 400 | {"error": "Ожидается JSON-объект в теле запроса"} |
| Денормализация упала (например, ждали int, прислали object) | 400 | {"error": "Ошибка десериализации: ..."} |
| Symfony Validator нашёл нарушения | 400 | сериализованный ConstraintViolationList (type, title, detail, violations: [{propertyPath, title, template, parameters}]) |
| Удаление упало в БД (FK / not null / unique) | 500 | {"error": "Ошибка при удалении записи", "message": "..."} |
| Успешный delete | 204 | пустое тело |
Create:
return $this->crud->create($request, News::class, ['news:write'], ['news:read']);
Update:
return $this->crud->update($request, $news, ['news:write'], ['news:read']);
Delete:
return $this->crud->delete($news);
Почему не ручной updateEntity
Раньше в сервисах были большие методы вида:
if (array_key_exists('name', $data)) {
$news->setName($data['name']);
}
if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
$v = $data['regionId'] ?? $data['region_id'];
$news->setRegionId($v === null || $v === '' ? null : (int) $v);
}
Такой подход плохо масштабируется:
- одинаковые проверки копируются между сущностями;
- легко забыть поле;
- легко по-разному обработать один и тот же тип;
- controller/service превращается в ручной mapper;
- сложнее держать безопасность записи через allowlist.
Теперь allowlist полей задаётся serializer groups прямо в entity:
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['news:read', 'news:write'])]
private ?string $name = null;
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['news:read'])]
private ?int $id = null;
Если поле не входит в *:write, клиент не может изменить его через PUT.
По текущей проверке write-группы у контентных сущностей содержат только scalar/json поля. ORM-связей (OneToMany, ManyToMany и т.п.) в этих шести сущностях нет. Если такие связи появятся позже, их нельзя автоматически добавлять в *:write: сначала нужно отдельно решить, можно ли менять эту связь через публичный/admin JSON API.
Особенность id
Для публичного/admin CRUD id должен генерироваться приложением/БД, а не приходить от клиента. У контентных сущностей настроен GeneratedValue(strategy: "IDENTITY"), а миграция добавляет sequence/default для таблиц, где id раньше был assigned.
Поэтому поведение такое:
- на
POST /createCrudResponderпо умолчанию удаляетidиз payload до десериализации; - на
PUT /{id}CrudResponderвсегда удаляетidиз payload до десериализации; - даже если кто-то случайно добавит
idв*:writegroup, первичный ключ не будет перезаписан через HTTP CRUD.
Это защищает от ситуации, когда клиент обновляет /news/10, но присылает "id": 999 и случайно/намеренно меняет первичный ключ.
Пример create:
{
"name": "Новость из админки",
"active": true,
"regionId": 91,
"alias": "admin-news",
"anons": "Короткий анонс",
"content": "Полный текст"
}
Пример update:
{
"id": 123,
"name": "Новое название",
"active": false
}
В этом update поле id будет проигнорировано.
Синхронизация из Bitrix/view остаётся отдельным SQL-процессом: syncFromView* по-прежнему вставляет исторические id напрямую через INSERT ... SELECT ... ON CONFLICT. PostgreSQL sequence настроена так, чтобы новые id брались выше текущего MAX(id).
update_at
Поле updateAt больше не входит в *:write, поэтому фронтенд не управляет датой обновления напрямую.
Для автоматического заполнения используется Doctrine lifecycle callback:
#[ORM\HasLifecycleCallbacks]
class News
{
use UpdateTimestampTrait;
}
UpdateTimestampTrait:
- на
PrePersistставит текущую метку времени (\DateTimeImmutable), еслиupdateAtещё пустой; - на
PreUpdateобновляетupdateAtновойDateTimeImmutable; - в трейте объявлен
@property \DateTimeInterface|null $updateAtдля статического анализа (само поле остаётся в сущности с#[Groups]).
Импорт из Bitrix/view продолжает писать update_at напрямую SQL-ом и не зависит от HTTP CRUD lifecycle callbacks.
Синхронизация из Bitrix/view
У сервисов NewsCrudService, PromoCrudService, DiseaseCrudService, MedicalCenterCrudService, SiteServiceCrudService теперь оставлена только ответственность за импорт:
NewsCrudService::syncFromViewNews();PromoCrudService::syncFromViewPromo();DiseaseCrudService::syncFromViewDisease();MedicalCenterCrudService::syncFromViewCenters();SiteServiceCrudService::syncFromViewServices().
Эти методы используются командами:
UploadNewsCommand;UploadPromoCommand;UploadDiseasesCommand;UploadMedicalCentersCommand;UploadSiteServicesCommand.
Идея разделения:
- HTTP CRUD - это controller +
CrudResponder+Paginator+ repository; - импорт из внешних view - это sync service + console command.
Так backend-разработчику проще понять, где менять API-поведение, а где менять импорт.
Swagger
Контентные маршруты должны попадать в Swagger через apps/backend/config/packages/nelmio_api_doc.yaml.
Path patterns:
path_patterns: [
'^/news($|/)',
'^/promo($|/)',
'^/disease($|/)',
'^/medical-center($|/)',
'^/article($|/)',
'^/site-services($|/)'
]
Swagger UI локально:
http://localhost:8081/docs
OpenAPI JSON:
http://localhost:8081/api/doc.json
Как добавить новый похожий CRUD-ресурс
- Проверить entity и serializer groups:
#[Groups(['example:read', 'example:write'])]
private ?string $name = null;
#[Groups(['example:read'])]
private ?\DateTimeInterface $updateAt = null;
#[Groups(['example:read'])]
private ?int $id = null;
- Если у сущности есть
updateAt, подключить lifecycle callbacks:
#[ORM\HasLifecycleCallbacks]
class Example
{
use UpdateTimestampTrait;
}
- В repository добавить
createFilteredQueryBuilder:
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('e')->orderBy('e.id', 'DESC');
$this->applyCommonFilters($qb, 'e', $filters);
return $qb;
}
-
В controller подключить
CrudResponderиPaginator. -
Для list использовать (для легаси-поведения «только активные», если клиент не передал
active, см. второй аргумент — как у/news,/promo,/medical-center,/site-services):
$qb = $repository->createFilteredQueryBuilder(
ContentFilterDto::fromRequest($request, defaultActive: true),
);
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
]);
Если фильтр по активности по умолчанию не нужен (/disease, /article), передайте только $request.
- Для write-операций использовать:
return $this->crud->create($request, Example::class, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->update($request, $example, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->delete($example);
-
Добавить
#[OA\RequestBody(... Model(... groups: self::WRITE_GROUPS))]наcreate()иupdate(). -
Добавить path pattern в
nelmio_api_doc.yaml. -
Проверить маршруты:
php bin/console debug:router --env=dev
Проверка после изменений
Внутри контейнера backend:
cd /var/www/backend
composer dump-autoload -o
php bin/console cache:clear --env=dev --no-warmup
php bin/console cache:warmup --env=dev
php bin/console debug:router --env=dev
Публичная проверка списка:
curl 'http://localhost:8081/news/list?page=1&perPage=2'
Ожидаемый минимум:
{
"data": [],
"pagination": {
"total": 0,
"count": 0,
"per_page": 2,
"current_page": 1,
"total_pages": 1,
"has_previous_page": false,
"has_next_page": false
}
}
Проверка legacy-контракта статей:
curl 'http://localhost:8081/article/list?page=1&limit=2'
Ожидаемые верхнеуровневые ключи:
{
"data": [],
"meta": {
"total": 0,
"page": 1,
"limit": 2,
"totalPages": 1
}
}
Для POST, PUT, DELETE нужен JWT пользователя с ролью ROLE_ADMIN.
Что не надо возвращать обратно
Не стоит возвращать:
- отдельные
getPaginatedList()в каждый CRUD-service; - ручные
findByFilters()иcountByFilters()для каждой сущности; json_decodeв каждом controller;- большие
updateEntity()с десяткамиarray_key_exists; - ручное изменение
idвPUT.
Эти вещи уже закрыты общими классами и serializer groups.
Ветки Git и MR-файлы
Чтобы не смешивать архитектуру и шесть CRUD в одном MR, история разнесена по веткам. Ветка issues/27 — интеграционная (всё сразу), для новых MR не использовать.
origin/dev
└── feature/content-crud-architecture # общая инфраструктура (MR #1)
├── feature/content-crud-news # MR по ресурсу
├── feature/content-crud-promo
├── feature/content-crud-disease
├── feature/content-crud-medical-center
├── feature/content-crud-article
└── feature/content-crud-site-services
UI админки: admin-panel-content-crud — ветка issues/27-refactor от dev.
Файлы MR (корень монорепозитория)
Сгенерированы скриптом scripts/generate-backend-mr.sh (база origin/dev, не в git):
| MR | diff | HTML |
|---|---|---|
| Архитектура | mr-backend-content-crud-architecture.diff |
MR/mr-backend-content-crud-architecture.html |
| Новости | mr-backend-content-crud-news.diff |
MR/mr-backend-content-crud-news.html |
| Промо | mr-backend-content-crud-promo.diff |
MR/mr-backend-content-crud-promo.html |
| Заболевания | mr-backend-content-crud-disease.diff |
MR/mr-backend-content-crud-disease.html |
| Медцентры | mr-backend-content-crud-medical-center.diff |
MR/mr-backend-content-crud-medical-center.html |
| Статьи | mr-backend-content-crud-article.diff |
MR/mr-backend-content-crud-article.html |
| Услуги сайта | mr-backend-content-crud-site-services.diff |
MR/mr-backend-content-crud-site-services.html |
Пересборка: ./scripts/generate-backend-mr.sh из корня репозитория.
MR 1 — feature/content-crud-architecture
Показать разработчикам общую доработку (без контроллеров и без привязки к одной сущности):
| Файл | Назначение |
|---|---|
src/Dto/Content/ContentFilterDto.php |
query-параметры list (search, page, regionId, active) |
src/Service/Pagination/Paginator.php |
Pagerfanta, pagination / meta |
src/Repository/ContentFilterTrait.php |
фильтры в QueryBuilder |
migrations/Version20260515142000.php |
IDENTITY + SEQUENCE / setval для контент-таблиц |
src/Service/Crud/CrudResponder.php |
create/read/update/delete, валидация, denormalize |
src/Entity/Behavior/UpdateTimestampTrait.php |
updateAt на persist |
В полной ветке architecture также есть config/validator/ContentCrud.yaml и правки всех Entity — в точечных MR по ресурсу попадают изменения своей сущности (см. ниже).
MR 2…7 — ветки по ресурсу
Частичная реализация поверх архитектуры: тонкий контроллер, репозиторий с ContentFilterTrait, *CrudService (sync из view), правки entity.
Пример новостей (feature/content-crud-news):
| Файл | Что в MR |
|---|---|
config/packages/nelmio_api_doc.yaml |
только строка `'^/news($ |
src/Controller/NewsController.php |
CRUD-маршруты |
src/Entity/News.php |
trait, groups, GeneratedValue |
src/Repository/NewsRepository.php |
list + фильтры |
src/Service/NewsCrudService.php |
sync из view |
| Ветка | Свои файлы (аналогично news) |
|---|---|
feature/content-crud-promo |
PromoController, Promo.php, PromoRepository, PromoCrudService, `'^/promo($ |
feature/content-crud-disease |
DiseaseController, Disease.php, DiseaseRepository, DiseaseCrudService, `'^/disease($ |
feature/content-crud-medical-center |
MedicalCenterController, MedicalCenter.php, MedicalCenterRepository, MedicalCenterCrudService, `'^/medical-center($ |
feature/content-crud-article |
ArticleController, Article.php, ArticleRepository, `'^/article($ |
feature/content-crud-site-services |
SiteServiceController, SiteService.php, SiteServiceRepository, SiteServiceCrudService, `'^/site-services($ |
Порядок мержа: architecture → любые resource (параллельно). issues/27 — только локальная интеграция, не для review.