Skip to content

ТЗ — Конструктор форм (cms/form-builder)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

🔄 Ревизия (волна F корп-MVP, 15.07.2026, реализация): построен срез поверх ядрового контракта FormRepository (создан этой же волной — «⚠️ Противоречие» ниже закрыто): таблица пресетов + 4 системных (contact/callback/rfq/question, cms:form-builder:sync-presets), сервис «форма из пресета», Filament-конструктор полей формы (типы, реально рендерящиеся публичным партиалом ядра: text/tel/email/url/number/textarea/select/bool; лимит max_fields_per_form; optimistic-lock), уведомления получателям (settings.notify_emails) через NotificationDispatch на LeadCreated. Ядро волной F: schema-driven блок form (BC-fallback), динамические rules сабмита, per-form throttle, шов капчи, success_text. Вне среза: собственный API модуля, сабмишен→запись типа контента, per-city получатели, legacy-импорт, право form-builder.* (переиспользован forms.manage ядра — осознанно). Ограничение: LeadCreated не несёт payload лида — имя отправителя в уведомлении недоступно (вопрос ядра).

🔄 Ревизия (корпоративный MVP, 15.07.2026): по пивоту на движок типов контента сабмишен формы может уходить не только в LeadService ядра, но и записью в тип контента — граница гибкая, не жёсткая (content-types-engine §1). Выбор цели — настройка form-builder.submission_target (см. «Настройки»); та же валидация сабмита ядра (rate-limit, honeypot, капча, consent_at) действует для обеих целей.

Назначение и возможности

Тонкая надстройка-редактор над ядровыми cms_forms/LeadService, а не свой конвейер лидов. Ядро уже владеет данными формы (cms_forms), данными заявки (cms_leads), движком полей, honeypot/капчей, rate-limit и POST /api/v1/forms/{slug}/submit — весь приём и защита сабмита живут там и модулем не переопределяются. Пробел, который ядро осознанно оставляет: у него есть таблица + голый CRUD API (/api/v1/admin/forms), но нет ни продуктового Filament-конструктора форм для клиента, ни готовых типовых шаблонов. cms/form-builder закрывает именно этот пробел:

  • готовые формы-пресеты («Контакты», «Заказ», «Запрос КП», «Обратный звонок») — декларативные шаблоны схемы формы, старт в один клик вместо сборки с нуля;
  • конструктор произвольных полей — repeater типов движка полей поверх схемы формы (текст/телефон/email/textarea/select/чекбокс согласия/…), без кода на каждый новый тип;
  • Filament-ресурс «Формы» — редактирование, предпросмотр, настройка получателей и маркировки лида для формы, привязанной к странице/блоку;
  • дополнительные props рендера ядрового блока «форма» (макет в одну/две колонки, текст кнопки, текст успеха) — оформление, не данные.

Модуль не хранит сабмишены сам: они уходят в лиды ядра ИЛИ записью типа контента (настройка form-builder.submission_target, см. «Настройки») — ни для одного из вариантов form-builder не вводит свою публичную точку приёма сабмита и не реализует honeypot/капчу/throttle заново. Если понадобится что-то ещё — это повод пересмотреть контракт ядра, а не обойти его в модуле (анти-паттерн «свой мини-фреймворк там, где есть механизм ядра», §standard).

Зависимости и выключение

requires: ядро (cms/core-contracts: движок полей FieldTypeRegistry, LeadService, запись/чтение cms_forms через контракт ядра — см. «⚠️ Противоречие» ниже) · suggests: cms/notifications-bus (уведомление о заявке — модуль форм только указывает получателей, отправка — бас), cms/integration-yandex / cms/integration-google (провайдер капчи — сам виджет и проверка капчи остаются на ядре и провайдере, form-builder лишь предлагает выбор в UI), cms/multicity (получатели уведомлений и таргетинг формы по текущему городу посетителя из RequestContext, по аналогии с городским таргетингом в cms/banners/cms/popups).

Без cms/multicity — получатели формы не варьируются по городу, письмо всегда уходит на общий список (nullable-измерение, не отдельная ветка кода). Без cms/notifications-bus — уведомление о новой заявке уходит в log-fallback ядра (заявка всё равно создана и видна в Filament-ресурсе лидов ядра). Без провайдера капчи — форма остаётся с honeypot ядра, поле капчи в конструкторе скрыто/недоступно к выбору.

Выключение модуля: блок «форма» деградирует к базовому фиксированному рендеру ядра (без дополнительных props макета) — форма остаётся кликабельной и рабочей, сабмит продолжает идти через ядровый POST /api/v1/forms/{slug}/submit независимо от состояния form-builder. Пресеты и Filament-конструктор недоступны, существующие формы и заявки не меняются и не удаляются. Публичный сайт не падает.

Модель данных

Модуль не владеет cms_forms/cms_leads — это ядро (§4 стандарта: «чужие таблицы — только чтение через сервисы владельца»). Единственная своя таблица — декларативный каталог пресетов:

ТаблицаКлючевые поляПримечание
cms_form_builder_presetsid, slug, name, schema (jsonb), is_system (bool), site_id (nullable), lock_version, external_id (nullable)шаблон формы: набор Field-деклараций движка полей + дефолтные настройки (маркировка лида, антиспам-профиль)

slug уникален; is_system — системные пресеты (контакт/заказ/запрос КП/обратный звонок), поставляемые сидером, не удаляемые из UI, только клонируемые в пользовательский пресет; site_id nullable — при cms/multisite пресет либо общий (null), либо принадлежит конкретному сайту; lock_version — optimistic lock на конкурентную правку пресета в админке; external_id — только legacy-импорт (§16 стандарта). Никакого хардкода форм в коде: даже системные пресеты — строки таблицы из сидера, не PHP-массивы в классе.

Поля конкретной формы (какие есть у неё поля, получатели, маркировка лида, антиспам) хранятся в cms_forms ядра — form-builder только пишет туда через контракт ядра.

⚠️ Противоречие. Канонический реестр cms/core-contracts (core.md) даёт ContentRepository/TaxonomyRepository для чтения контента ядра, но не декларирует явный контракт для программной записи в cms_forms (только HTTP /api/v1/admin/forms и Filament — оба реализация самого ядра). Form-builder не может писать в чужую таблицу SQL напрямую (§4 стандарта) и не может звать собственный HTTP /api/v1/admin/forms изнутри PHP («HTTP к самому себе» запрещён §5 стандарта). Предложение: симметричный ядру контракт FormRepository (создать/обновить/прочитать декларацию формы, аналогично ContentRepository) в cms/core-contracts — нужна ревизия ядра до старта разработки модуля; до тех пор пункт дублируется в открытых вопросах.

ПДн-паспорт: cms_form_builder_presets не содержит ПДн (это шаблоны схемы, не данные посетителей). Данные посетителя (ФИО, телефон, email из сабмита) целиком живут в cms_leads ядра — ретеншн, «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) реализует PrivacyRegistry ядра, form-builder к этим данным доступа не имеет и в экспорт/забывание не встраивается (декларация «ПДн не храню»).

Входные и выходные данные

Вход:

ИсточникПоляВалидация
Admin CRUD пресета (/api/v1/admin/form-builder/presets)slug, name, schema, is_systemFormRequest-whitelist; schema — валидируется движком полей (FieldTypeRegistry) поэлементно, недопустимый тип поля → 422; is_system=true — только studio-роль; lock_version сверяется — иначе 409
«Создать форму из пресета» (/api/v1/admin/form-builder/forms/{form}/apply-preset)preset_slugпресет обязан существовать; применяется к форме через контракт записи ядра (см. «⚠️ Противоречие»), не поверх существующих полей молча — предпросмотр разницы перед подтверждением
Filament-конструктор полей формы (repeater)набор Field-деклараций (тип/label/required/rules/…)rules движка полей на каждое поле; лимит form-builder.max_fields_per_form; запрещённые для формы типы полей (стилевые — как и в схеме блока, §Q9 движка полей) отклоняются на регистрации
Настройка получателей/маркировки формыrecipients[] (email), lead_type (строка → cms_leads.type), antispam_profileemail — формат + существование хотя бы одного получателя перед публикацией формы; lead_type — из whitelist значений, объявленных настройкой form-builder.available_lead_types
Публичный сабмит формыне вход модуля — целиком ядровый POST /api/v1/forms/{slug}/submit (rate-limit, honeypot, капча, consent_at — всё ядро); form-builder только сконфигурировал схему и получателей заранее

При form-builder.submission_target=content_entry тот же ядровый сабмит (валидация, honeypot, капча, consent_at не меняются) вместо LeadService::create() записывает результат через API движка типов контента (POST /api/v1/content/{type}, content-types-engine §1) — теми же rules движка полей, что и обычная запись типа; целевой тип — только из whitelist form-builder.content_entry_targets (см. «Настройки»), произвольный тип не принимается.

Выход:

ПотребительДанныеФормат
Блок «форма» (рендер ядра + props form-builder)схема полей формы (через FieldEngine ядра) + макет (layout, submit_text, success_text)props блока, версия _v, demo-props в галерее
Filament-предпросмотррендер формы «как увидит посетитель» без реального сабмитаHTML-превью в модальном окне ресурса
Публичный API GET /api/v1/forms/{slug}схема формы для headless-рендеране эндпоинт модуля — ядровый, form-builder лишь наполнил cms_forms
Admin API /api/v1/admin/form-builder/...CRUD пресетов, применение пресета к формеконверт {data, meta}
Legacy-импортотчёт прогона--json: создано/обновлено/пропущено/ошибки построчно

Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта): в частности, form-builder не принимает произвольный сабмит формы — эта граница закрыта ядром, дублировать или обходить её модулю запрещено.

Настройки (группа form-builder)

КлючТипДефолтaffectsPageCacheОписание
form-builder.available_presetsarrayсистемные 4 (contact, order, rfq, callback)нетКакие пресеты предлагать в «Создать из пресета»
form-builder.available_lead_typesarray["contact","order","rfq","callback"]нетWhitelist значений маркировки lead_type, доступных при настройке формы
form-builder.default_recipientsarray (email)[]нетПолучатели по умолчанию для новой формы, если явно не указаны
form-builder.default_antispam_profilestringhoneypot_onlyнетДефолт для новых форм: honeypot_only/captcha_required (сам honeypot/капча — механизм ядра, здесь только выбор профиля по умолчанию)
form-builder.max_fields_per_formint30нетЛимит полей в конструкторе (защита от неюзабельной формы и раздутого payload)
form-builder.kill_switchboolfalseАварийное отключение расширенного рендера блока «форма»: откат к базовому фиксированному шаблону ядра без выключения модуля
form-builder.submission_targetenum: lead|content_entryleadнетКуда уходит сабмишен формы: лид ядра (LeadService) или запись типа контента через API движка (см. «Назначение и возможности», ревизия 15.07.2026)
form-builder.content_entry_targetsarray (slug типов контента)[]нетWhitelist типов контента, разрешённых как цель сабмишена при submission_target=content_entry; защита от записи в произвольный тип

API

МетодПутьДоступНазначение
GET/POST/PUT/DELETE/api/v1/admin/form-builder/presets…form-builder.manageCRUD пресетов (cms_form_builder_presets)
POST/api/v1/admin/form-builder/forms/{form}/apply-presetform-builder.manageПрименение пресета к существующей/новой ядровой форме
GET/api/v1/admin/form-builder/forms/{form}/previewform-builder.manageРендер предпросмотра формы (без реального сабмита)

Публичной точки приёма сабмита модуль не вводит — используется только ядровый POST /api/v1/forms/{slug}/submit. CRUD форм по существу (cms_forms) остаётся за ядровым /api/v1/admin/forms…; form-builder добавляет вокруг него UX-слой (пресеты, предпросмотр), не параллельный API той же сущности.

Компоненты

Filament: ресурс «Формы» (навигационная группа модуля) — конструктор полей формы (repeater типов движка полей: text/tel/email/textarea/select/checkbox-согласие/…), выбор пресета как старт («Создать из пресета» с превью разницы), настройка получателей уведомлений и маркировки лида (lead_type), выбор антиспам-профиля (ссылка на настройки ядра forms.captcha_*, не дублирование), предпросмотр формы в модальном окне, afterSave() → инвалидация тега form-builder (и уведомление core о смене схемы формы, если требуется сброс page-cache страниц с этим блоком). Ресурс «Пресеты» — CRUD шаблонов, is_system — только для чтения/клонирования у обычных ролей.

Блок: дополнительные props поверх ядрового блока «форма» — макет (layout: single-column/two-column), текст кнопки, текст сообщения об успехе; версия _v, demo-props, fallback на базовый рендер ядра при выключении/сбое form-builder (без CLS — резерв высоты полей формы), доступность с клавиатуры (label на каждое поле, порядок табуляции по порядку полей в схеме).

Команды: cms:form-builder:seed-presets --json (пересоздание системных пресетов — идемпотентно), cms:form-builder:import-legacy --source=<профиль> --json. Демо-сидер: 4 системных пресета + одна демо-форма на пресете «Контакты» для галереи блоков/playground.

События и обмен

Модуль не издаёт новых доменных фактов помимо стандартного SettingChanged (настройки группы form-builder) — создание/статус заявки остаётся LeadCreated/LeadStatusChanged ядра, form-builder их не порождает и не переопределяет.

Слушает: LeadCreated — только чтобы (опционально) обогатить письмо в notifications-bus данными формы-источника (название формы, пресет), если форма была создана через form-builder; при выключенном notifications-bus — обработчик не регистрируется (log-fallback ядра, form-builder ни на что не влияет).

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms_forms (ядро)сервис-вызов cms/core-contracts (см. «⚠️ Противоречие»)form-builder → ядрозапись схемы полей/получателей/маркировки формы через контракт ядра, не SQL
LeadService (ядро)сервис-вызов requires (канал 4)ядро → лид создан вне form-builderform-builder не вызывает LeadService::create() напрямую — сабмит целиком обрабатывает ядровый роут; факт LeadCreated form-builder только слушает
LeadCreated (ядро)шина событий (канал 1)ядро → form-builderопциональное обогащение уведомления данными пресета/формы-источника
cms/notifications-bus (suggests)provides-контракт notification-channel через NotificationDispatchform-builder → басуказание получателей формы транслируется в адресатов уведомления о заявке; не установлен — log-fallback ядра
cms/integration-yandex/integration-google (suggests)provides-контракт captcha-providerформа → провайдерform-builder предлагает выбор капчи в UI; сама проверка — ядро+провайдер, не модуль
cms/multicity (suggests)сервис-вызов RequestContextядро → form-builderполучатели/таргетинг формы по текущему городу; не установлен — измерение nullable, получатели общие
Настройка form-builder.kill_switchsettings-storeform-builder → рендер блокавключена — блок «форма» рендерится базовым ядровым шаблоном без доп. props

Фоновая работа

Джобы отсутствуют кроме легаси-импорта (батч-командой, не очередью на горячем пути). Отправка уведомления о заявке, throttle и очередь доставки — целиком notifications-bus и ядро; form-builder только конфигурирует получателей на входе.

Производительность и кеш

Горячий путь — рендер блока «форма» на публичной странице: form-builder не добавляет собственных запросов сверх того, что уже делает ядровый рендер блока (схема формы читается и кешируется ядром по тегу формы); дополнительные props (layout, тексты) хранятся в props самого блока (JSONB ревизии страницы), отдельного запроса не требуют — 0 лишних запросов на горячем пути (контрактный тест).

Тег кеша form-builder объявляется через CacheTags и инвалидируется при изменении пресета или при применении пресета к форме (само изменение схемы формы инвалидирует ядровый тег формы — form-builder не трогает чужие теги, только свой). Список пресетов в Filament («Создать из пресета») кешируется по тегу form-builder группы настроек — 0 запросов на построение UI-списка на горячем пути админки.

Безопасность

Границы входа: FormRequest-whitelist на CRUD пресетов и на настройки формы (получатели, маркировка, антиспам-профиль); schema пресета и формы валидируется движком полей поэлементно — произвольный JSON без прогонки через FieldTypeRegistry не принимается. Публичный сабмит формы не является границей входа модуля — вся фильтрация (honeypot, капча, rate-limit, санитизация rich-text полей) — стандартизированные обработчики ядра, form-builder их не обходит и не дублирует.

is_system=true у пресета — только studio-роль (защита от порчи системных шаблонов клиентом). Email получателей — валидируются форматом на входе; список получателей не попадает в публичный рендер формы (только в конфигурацию, читаемую сервером при отправке уведомления).

Матрица ролей:

Действиеadminменеджерредакторstudio
Создание формы из пресета
Конструктор произвольных полей формы✅ (без смены получателей/маркировки)
Настройка получателей/маркировки лида/антиспам-профиля
CRUD пресета пользовательского (is_system=false)
CRUD системного пресета (is_system=true)
Удаление формы с историей заявок✅ (с подтверждением)
form-builder.kill_switch (аварийное отключение)

Права: form-builder.view, form-builder.manage.

UX-требования

Админ:

  • Пустой список форм — не пустая таблица, а подсказка «Создайте первую форму из готового шаблона» с кнопками по числу системных пресетов.
  • «Создать из пресета» на уже существующей форме показывает предпросмотр разницы схемы перед применением (какие поля добавятся/изменятся), не тихую перезапись.
  • Ошибка превышения form-builder.max_fields_per_form — конкретный текст («Достигнут лимit 30 полей — уберите неиспользуемые перед добавлением нового»), не generic «Error».
  • Форма без получателей уведомлений — предупреждение при попытке публикации («Некому получать заявки с этой формы — укажите email»), не тихая публикация в никуда.
  • Конфликт редактирования пресета (lock_version) — человеческое сообщение «Пресет изменён другим пользователем, обновите страницу».
  • Массовое действие в списке пресетов: клонировать системный пресет в пользовательский (стартовая точка для кастомизации без риска сломать системный).

Посетитель:

  • Форма не создаёт CLS: высота полей зарезервирована по схеме до полной загрузки стилей.
  • Ошибка валидации сабмита (ядро) сохраняет введённые значения — form-builder не переопределяет это поведение, только не должен его ломать своими доп. props макета.
  • Форма доступна с клавиатуры: каждое поле схемы — с label, порядок табуляции совпадает с порядком полей в конструкторе.

Крайние случаи и типовые баги

  • Применение пресета к форме, у которой уже есть кастомные поля — предпросмотр разницы обязателен (см. UX); слепое слияние без подтверждения — незачёт.
  • Удаление формы с историей заявок — заявки в cms_leads не удаляются (ссылка формы на лида мягкая/по slug/id формы на момент сабмита, не каскадное удаление); form-builder не инициирует и не блокирует удаление формы ядром, только предупреждает в UI числом связанных заявок.
  • Удаление пресета, уже применённого к формам ранее — не влияет на существующие формы (пресет — разовый шаблон-донор полей на момент применения, не постоянная связь).
  • Превышение form-builder.max_fields_per_form при конструировании — понятная ошибка на попытке добавить поле сверх лимита, не тихое обрезание списка при сохранении.
  • Форма без обязательного поля email/телефона — конфигурируемо самим клиентом (form-builder не навязывает обязательные поля, это решение автора формы), в отличие от фиксированного дефекта «нет поля email» у голого ядрового блока формы.
  • Honeypot заполнен ботом → сабмит ядра отдаёт фейковый success — form-builder об этом не узнаёт и не должен: поведение целиком на стороне ядра, конструктор его не видит и не логирует отдельно.
  • Конкурентная правка одной формы двумя менеджерами (репитер полей) — lock_version формы (ядро) отдаёт 409 при сохранении второго; form-builder показывает то же человеческое сообщение, что и остальная админка ядра.
  • cms/notifications-bus выключен посреди работы формы — заявки продолжают создаваться (LeadCreated ядра независим), уведомление уходит в log-fallback; form-builder не блокирует сабмит и не считает это ошибкой формы.
  • Выключенный cms/multicity при выбранном в конструкторе таргетинге получателей по городу — условие города игнорируется, получатели общие (nullable-измерение, оба режима покрыты контрактным тестом, как в cms/banners/cms/popups).
  • Системный пресет случайно удалён из available_presets настройкой, но остаётся единственным пресетом с is_system=true в БД — данные не теряются, просто временно не предлагается в UI; восстановление — правка настройки, не пересоздание пресета.
  • ⚠️ Противоречие: запись схемы формы в cms_forms ядра у form-builder не имеет канонического PHP-контракта в cms/core-contracts (см. «Модель данных») — до ревизии ядра (добавление FormRepository, симметрично ContentRepository) конструктор технически не может безопасно писать в чужую таблицу ни SQL, ни HTTP-к-себе. Блокер реализации, не блокер ТЗ — дублируется в открытых вопросах.

Донорский код

Прямого одного донора нет (★): паттерн пресетов близок к конструкторам форм в CMS-практике студии (типовые формы «контакт/заказ/КП/звонок» повторяются в большинстве проектов вручную) — form-builder формализует то, что раньше собиралось руками в каждом проекте поверх universal.

Legacy-импорт: cms:form-builder:import-legacy --source=<профиль> — маппинг старых форм донорских проектов (набор полей → декларации движка полей, получатели, тип заявки) на cms_form_builder_presets + запись целевой формы в cms_forms ядра через контракт записи; идемпотентен по external_id (повторный прогон обновляет, не дублирует); --dry-run выводит отчёт расхождений без записи. Прогон на копии боевых данных донора — часть приёмки модуля (после закрытия «⚠️ Противоречие» выше).

Тесты и приёмка

  • [ ] Применение системного пресета создаёт форму со схемой, валидной для движка полей
  • [ ] Применение пресета к форме с существующими полями требует подтверждения (превью разницы), не сливает поля молча
  • [ ] Права form-builder.manage разграничены от голого CRUD /api/v1/admin/forms ядра; матрица ролей покрыта тестом
  • [ ] is_system=true недоступен для CRUD никому, кроме studio-роли
  • [ ] Превышение form-builder.max_fields_per_form — человеческая ошибка, не 500 и не тихое обрезание
  • [ ] При выключении form-builder блок «форма» рендерится базовым шаблоном ядра без 500
  • [ ] Публичный сабмит формы работает идентично независимо от состояния form-builder (ядровый роут не зависит от модуля)
  • [ ] Удаление формы с историей заявок не удаляет и не портит заявки в cms_leads
  • [ ] Конкурентная правка пресета/формы → 409 по lock_version
  • [ ] Выключенный cms/multicity/cms/notifications-bus не блокирует конструктор и сабмит (log-fallback/nullable-измерение, оба режима в тестах)
  • [ ] Legacy-импорт идемпотентен по external_id, --dry-run не пишет в БД
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут модуля (пресеты, apply-preset, preview)
  • [ ] Тестовая БД только form-builder_test; migrate:fresh/refresh/reset/db:wipe запрещены

Внутренняя база знаний студии