Конструктор форм — классическая ловушка. Начинается с «пусть заказчик сам добавит поле», заканчивается собственным недоделанным языком описания интерфейсов, который знаете только вы.
У меня получилось не скатиться туда, и, кажется, помогло одно ограничение: я запретил себе делать конструктор расположения. Он собирает структуру данных, а не вёрстку.
Четыре уровня
Анкета (Form)
└─ Шаг (FormStep)
└─ Блок (FormBlock)
└─ Поле (FormField)Первый вопрос, который мне задавали: зачем блок, если есть шаг? Шаг — это единица навигации, отдельный экран с кнопкой «Далее». Блок — единица группировки внутри экрана, у него свой заголовок и число колонок.
Без блока получается либо шаг на каждые три поля — двенадцать шагов в анкете, — либо одна плоская простыня из двадцати полей. Разделение появилось не из архитектурных соображений, а после первой же анкеты, которую собрал заказчик.
Блок несёт ровно два оформительских параметра: заголовок и количество колонок от одной до трёх. Это весь конструктор расположения, который я разрешил. Границу держу жёстко: любая просьба вида «а можно это поле сделать пошире» получает ответ «нет».
Сборка в коде
Стандартные анкеты описываются в коде, потому что их нужно версионировать и разворачивать вместе с релизом:
$form = $builder->buildForm('candidate_application', 'Анкета кандидата')
->addStep('Личные данные', 'Основная информация о кандидате', 'user')
->addBlock('Персональная информация', null, 2)
->addField('last_name', 'Фамилия', FieldType::Text, required: true)
->addField('first_name', 'Имя', FieldType::Text, required: true)
->addField('birth_date', 'Дата рождения', FieldType::Date, required: true)
->addBlock('Контакты')
->addField('email', 'Email', FieldType::Email, required: true)
->addField('phone', 'Телефон', FieldType::Phone, required: true)
->addStep('Опыт работы', null, 'briefcase')
->addBlock('Последнее место')
->addField('last_company', 'Компания', FieldType::Text)
->addField('last_position', 'Должность', FieldType::Text)
->getForm();Fluent API, или текучий интерфейс, здесь читается почти как разметка, и это единственная причина, по которой он выбран. Но у него есть неприятное свойство, о котором стоит знать заранее: addBlock() после addField() должен «подняться» на уровень выше.
Внутри это состояние строителя:
final class FormBuilderService
{
private ?Form $form = null;
private ?FormStep $currentStep = null;
private ?FormBlock $currentBlock = null;
public function addBlock(string $title, ?string $description = null, int $columns = 1): self
{
if (!$this->currentStep) {
throw new \LogicException('addBlock() до addStep()');
}
$this->currentBlock = new FormBlock($this->currentStep, $title, $description, $columns);
$this->currentStep->addBlock($this->currentBlock);
return $this;
}
}Скрытое состояние — цена за читаемость. Строитель нельзя переиспользовать между анкетами, нельзя собирать две анкеты параллельно, а ошибка в порядке вызовов ловится только исключением в рантайме.
Я это принял, потому что строитель вызывается в двух местах: в сидере и в команде импорта. Если бы он был публичным API, я бы выбрал вложенные замыкания — уродливее, зато без состояния.
Та же структура в базе
Анкеты, собранные через интерфейс, лежат в четырёх таблицах, повторяющих иерархию. Ничего экзотического: form, form_step, form_block, form_field, каждая ссылается на родителя и несёт порядковый номер.
Чтение — одним запросом с сортировкой по всем уровням:
public function loadWithStructure(string $code): ?Form
{
return $this->createQueryBuilder('f')
->addSelect('s', 'b', 'fld')
->leftJoin('f.steps', 's')
->leftJoin('s.blocks', 'b')
->leftJoin('b.fields', 'fld')
->where('f.code = :code')->setParameter('code', $code)
->orderBy('s.sortOrder')->addOrderBy('b.sortOrder')->addOrderBy('fld.sortOrder')
->getQuery()->getOneOrNullResult();
}Три leftJoin с выборкой всех уровней размножают родительские данные в результате SQL, но Doctrine собирает из строк нормальный граф объектов. Размер ответа зависит от числа полей и пустых ветвей структуры; для наших анкет один такой запрос всё равно оказался дешевле серии отдельных запросов по уровням.
Результат кешируется целиком: структура меняется раз в неделю, читается на каждое открытие формы.
Рендер многошаговой формы
Здесь я сделал самое неочевидное для себя решение — не хранить промежуточное состояние в сессии.
Каждый шаг сохраняется сразу в профиль кандидата, как только пройдена валидация этого шага. Профиль в статусе черновика, часть полей пустая — для этой модели это допустимое состояние.
Причины простые. Анкета кандидата заполняется около двадцати минут, а сессия может завершиться или потеряться. Человек уходит с шага 3, возвращается через день по ссылке из письма и продолжает. Рекрутёр видит незаконченную анкету и может позвонить, вместо того чтобы гадать, почему кандидат пропал.
До первого сохранения интерфейс должен прямо сказать, что черновик уже доступен рекрутёру, и назвать срок хранения. Правовое основание обработки определяется отдельно; прятать такое поведение до кнопки «Отправить» точно нельзя.
Валидация тоже пошаговая: проверяются только поля текущего шага. Полная проверка — при отправке анкеты целиком, потому что до этого момента обязательные поля следующих шагов заведомо пустые.
public function validateStep(CandidateProfile $profile, FormStep $step): ConstraintViolationListInterface
{
$violations = new ConstraintViolationList();
foreach ($step->allFields() as $field) {
$value = $profile->valueOf($field->getCode());
$violations->addAll(
$this->validator->validate($value, $this->constraints->build($field))
);
}
return $violations;
}Кнопка «Назад» ничего не откатывает: значения уже сохранены, шаг просто перерисовывается с текущими данными.
Что делать с уже заполненными анкетами
Это тот вопрос, на котором конструкторы форм обычно и ломаются. Заказчик удаляет поле, а сто восемьдесят заполненных анкет ссылаются на него.
Я выбрал самый скучный вариант: поля не удаляются, а архивируются.
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;Архивное поле не показывается в новой анкете, но остаётся видимым в уже заполненных — с пометкой, что оно больше не используется. Значения не трогаются. Место в базе занимают, зато никто не приходит с вопросом «а где данные, которые я вводил в марте».
Изменение типа поля запрещено вообще. Хочется превратить текстовое поле в список — заводи новое поле. Попытка сконвертировать произвольный текст в набор вариантов заканчивается либо потерей данных, либо списком с двумястами вариантами.
Полноценного версионирования всей анкеты я не делал. Идея была: у каждой заполненной анкеты — снимок структуры на момент заполнения. Вместо этого зафиксировал свойства полей, от которых зависит интерпретация ответа, а расположение и состав активных полей оставил изменяемыми.
Копирование и анкета по умолчанию
Две функции, которые оказались востребованнее, чем я ожидал.
Копирование анкеты — это рекурсивный обход четырёх уровней с новыми идентификаторами. Заказчик делает «Анкету кандидата 2026» из прошлогодней, правит три поля и переключает флаг по умолчанию. Никакого конструктора с нуля.
Флаг «по умолчанию» — ровно один на систему. Одной транзакции недостаточно: два параллельных запроса всё равно могут попытаться выставить два флага. Инвариант держит частичный уникальный индекс, а переключение выполняется одной транзакцией:
CREATE UNIQUE INDEX uniq_default_form
ON form (is_default)
WHERE is_default = true;public function makeDefault(Form $form): void
{
$this->em->getConnection()->transactional(function ($connection) use ($form): void {
$connection->executeStatement(
'UPDATE form SET is_default = false WHERE is_default = true'
);
$connection->executeStatement(
'UPDATE form SET is_default = true WHERE id = :id',
['id' => $form->getId()]
);
});
}Прямой SQL вместо обхода сущностей — сознательно. Анкет может быть тридцать, гидрировать их ради сброса флага незачем. После вызова ранее загруженные объекты Form считаются устаревшими: репозиторий перечитывает их перед следующим использованием.
Что бы сделал иначе
Не начинал бы с текучего интерфейса. Он появился первым, потому что писать код было приятно, а редактор структуры в админке — нет. В итоге за полгода через код завели две анкеты, а через интерфейс — одиннадцать.
Ещё сразу заложил бы предпросмотр. Сейчас заказчик собирает анкету, сохраняет, открывает публичную ссылку и смотрит, что получилось. Три лишних действия на каждую правку — и это самая частая претензия, которую я слышу.