almdev Технический блог

Конструктор анкет: анкета, шаг, блок, поле
Четыре уровня иерархии, текучий интерфейс и вопрос, который убивает такие конструкторы.

Как устроен конструктор многошаговых анкет: зачем блок отдельно от шага, как одна структура живёт и в коде, и в базе, и что делать с уже заполненными анкетами при изменении формы.

Подкатегория: Данные

Чтение
6 мин
Технологии / версии
Symfony · Doctrine · Формы · API
23 июн 2026 · 6 мин · 2 просмотра · Backend
Многоуровневый механизм собирается из шагов, блоков и полей анкеты
Данные 06/2026

Конструктор форм — классическая ловушка. Начинается с «пусть заказчик сам добавит поле», заканчивается собственным недоделанным языком описания интерфейсов, который знаете только вы.

У меня получилось не скатиться туда, и, кажется, помогло одно ограничение: я запретил себе делать конструктор расположения. Он собирает структуру данных, а не вёрстку.

Четыре уровня

Анкета (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 считаются устаревшими: репозиторий перечитывает их перед следующим использованием.

Что бы сделал иначе

Не начинал бы с текучего интерфейса. Он появился первым, потому что писать код было приятно, а редактор структуры в админке — нет. В итоге за полгода через код завели две анкеты, а через интерфейс — одиннадцать.

Ещё сразу заложил бы предпросмотр. Сейчас заказчик собирает анкету, сохраняет, открывает публичную ссылку и смотрит, что получилось. Три лишних действия на каждую правку — и это самая частая претензия, которую я слышу.

#symfony #doctrine #formy #api