0% прочитано

Laravel Forms: як одна динамічна форма перетворюється на Conversation і Message без окремої логіки для кожної сторінки

Як у ICanUp одна reusable Form проходить public validation і anti-spam, отримує source context і перетворюється на Conversation та initial Message у спільному Communication Center замість окремих ContactFormController, SupportFormController і InquiryFormController.

26 вересня 2026 р. 9 хв читанняЛаравель

Форма Контакти, форма Запиту послуги і форма Підтримки зовні можуть виглядати по-різному. Але якщо для кожної з них створювати окремий controller, окремий submission model і окремий workflow, дуже швидко з’являються три версії майже однакової логіки.

У ICanUp я пішла іншим шляхом. Форма є окремою динамічною сутністю з визначенням збереженого поля. Сторінка лише показує потрібну Форму через один з контент блоків. Після сабміту усі форми проходять через один public pipeline, а результатом стає звичайна Conversation і перший Message у вже існуючому Communication Center.

Тобто сторінка не володіє логікою звернення. Вона лише є одним із місць, де багаторазова форма може бути показана.

Ця стаття не про localized validation errors. Тут головна тема - як одна архітектура форм повторно використовує існуючу архітектуру Комунікацій.

Контент блок Форма не є окремою contact form

Контент блок знає, яку Form потрібно відобразити. Але структура полів, options, required state, labels і правила валідації належать самій Form.

Це дозволяє вставити ту саму Form на різні Сторінки без дублювання робочого процесу бекенду. Якщо змінюється збережене definition, public submission перевіряється вже проти актуального визначення.

Для мене це важлива межа: Page відповідає за композицію контенту, Form - за структуру вводу, Communication - за подальше життя звернення.

Public submission спочатку проходить валідацію

Public endpoint не приймає довільний JSON і не передає його далі в Communication.

Відповіді перевіряються проти поточного визначення збереженого поля. Невідомі клбчі відповіді відхиляються. Select option має належати саме цій Form. Неактивна, видалена або відсутня Форма не повинна приймати submission.

Окремо перевіряється source context. Публічний клієнт може передати лише безпечний локальний path без host, query string або fragment.

PHP - Public Form submission has a bounded request shape
return [    'answers' => [        'required',        'array',    ],     'website' => [        'nullable',        'string',        'max:0',    ],     'source' => [        'nullable',        'array:path',    ],

Anti-spam стоїть перед створенням Conversation

Невалідний або очевидно автоматизований request не повинен залишати після себе Thread або Message.

Для цього submission flow має кілька дешевих перевірок до переходу в Communication: honeypot website, whitelist root keys і rate limiter.

Тести окремо перевіряють honeypot, unsafe source paths, unexpected keys і rate limiting. Інший regression test гарантує, що invalid submission не створює Communication state.

PHP - Reject unexpected root input and honeypot submissions
if (    $this->filled('website')    || array_diff(        array_keys(            $this->all()        ),        [            'answers',            'website',            'source',        ],    ) !== []) {    // Reject before a Communication    // conversation can be created.}
PHP - Rate limit by Form and requester
RateLimiter::for(    'form-submissions',    static function ($request): Limit {        $form =            $request->route('form');         $formIdentity =            $form instanceof Form                ? (string) $form->getKey()                : (string) $form;         $requester =            $request->user()                ? 'user:'                    .$request                        ->user()

Найкраще місце для anti-spam - до того, як зовнішній request перетворився на внутрішню бізнес-сутність.

Purpose визначає поведінку, а не окремий controller

Форма має мету. Саме вона визначає, який тип Conversation і яке джерело потрібно створити.

Project inquiry і service inquiry обидва використовують feedback behavior, але мають різний source context. Support використовує support behavior і support source.

Тому мені не потрібні окремі ProjectInquiryController, ServiceInquiryController і SupportFormController тільки для того, щоб направити payload у різні частини Communication Center.

PHP - Map Form purpose to Communication type and source
return match ($form->purpose) {    FormPurposeEnum::PROJECT_INQUIRY => [        ThreadTypeEnum::FEEDBACK,        ThreadSourceEnum::FORM_INQUIRY,    ],     FormPurposeEnum::SERVICE_INQUIRY => [        ThreadTypeEnum::FEEDBACK,        ThreadSourceEnum::SERVICE_INQUIRY,    ],     FormPurposeEnum::SUPPORT => [        ThreadTypeEnum::SUPPORT,        ThreadSourceEnum::SUPPORT,    ],};

Form purpose

Conversation type

Source

project_inquiry

feedback

form_inquiry

service_inquiry

feedback

service_inquiry

support

support

support

Type відповідає за поведінку Conversation, а source пояснює, звідки вона з’явилася.

Source context зберігає зв’язок із місцем відправлення

Conversation має знати не тільки "це feedback". Для адміністратора важливо бачити, з якої Form і з якої Page прийшло звернення, якою була locale і який local path відкрив користувач.

Для цього Form pipeline створює ThreadSourceContextData. Суб'єктом є сама Форма, а додатково можуть зберігатися origin Page, locale і source path.

Так Communication не залежить від конкретного Vue component, але все одно має достатній контекст, щоб правильно показати походження звернення.

PHP - Build Communication source context from the Form submission
sourceContext:    ThreadSourceContextData::make(        source: $threadSource,        subject: $form,        originPage: $originPage,        locale: $submission->locale,        sourcePath:            $submission->sourcePath,        availableLocales:            Form::adminLocales(),    ),

Conversation зберігає context як власні attributes

ThreadContextData переводить source context у поля самого Communication Thread: source, subject_type, subject_id, origin_page_id, source_locale і source_path.

Це важливо для довгого життя звернення. Conversation уже не залежить від request object і не потребує відновлювати походження з URL або тексту Message.

PHP - Persist source context on the Communication Thread
return [    'source' =>        $this->sourceContext->source,     'subject_type' =>        $subject?->getMorphClass(),     'subject_id' =>        $subject?->getKey(),     'origin_page_id' =>        $this            ->sourceContext            ->originPage            ?->getKey(), 
PHP - Regression test for persisted Form and Page context
expect($attributes)    ->toMatchArray([        'source' =>            ThreadSourceEnum::FORM_INQUIRY,         'subject_type' =>            'form',         'subject_id' =>            $form->id,         'origin_page_id' =>            $page->id,         'source_locale' =>            'uk',

Valid submission стає Conversation і initial Message

Після валідації форми submission не зберігається як ізольований "form response", який потім потрібно окремо обробляти в Admin.

Він одразу входить у звичайну Communication model: створюється Conversation і перший Message.

Це означає, що далі звернення може користуватися вже існуючими можливостями Communication Center: статусом Conversation, відповідями, participants, source presentation та іншою логікою, яка вже є для повідомлень.

Тести перевіряють важливу деталь: кожен valid Form submission створює окрему Conversation і Message. Це не thread, який випадково повторно використовується для всіх звернень із тієї самої Form.

Guest і authenticated submission проходять той самий pipeline

Для автентифікованого користувача система вже має стабільну user identity. Для guest submission Form може містити явно налаштовані identity fields, які потрібні для звернення.

Це не причина створювати два різні backend flows. Відмінність знаходиться всередині submission data та Communication context, а не в окремих controllers.

У тестах окремо перевіряються guest submission, authenticated submission, guest identity та association authenticated submission із Message і Conversation.

Service inquiry і Support залишаються одним Form mechanism

Різний business meaning не обов’язково означає різний transport pipeline.

Service inquiry має source service_inquiry і feedback behavior. Support має source і type support. Але до моменту mapping вони проходять той самий Form endpoint, persisted validation і anti-spam.

Різниця з’являється там, де вона справді має значення - при створенні Communication context.

PHP - Service inquiry keeps its source path and locale
expect($thread->source)    ->toBe(        ThreadSourceEnum::SERVICE_INQUIRY    )    ->and($thread->source_path)    ->toBe($sourcePath)    ->and($thread->source_locale)    ->toBe('en');

Чому я не зробила окрему submissions inbox

Можна було створити таблицю form_submissions, окремий Admin list, окрему сторінку перегляду і окрему кнопку Reply.

Але тоді я фактично почала б будувати другий Communication Center поруч із уже існуючим.

Замість цього Form відповідає лише за те, щоб перетворити валідний public input у правильний Communication context і початковий Message. Після цього власність переходить до Communication.

Це зменшує кількість спеціальної логіки й одночасно робить нові Form use cases дешевшими: нова сторінка або нова форма не потребують нового inbox.

Reuse тут означає не спільний helper. Воно означає, що різні входи приводять до однієї канонічної моделі Communication.

Що в результаті належить кожному шару

Шар

Відповідальність

ContentBlock / Page

Вибрати й показати потрібну Form у контенті.

Form

Описати fields, options, labels, purpose та validation contract.

Public Form submission

Перевірити answers, source context, honeypot і rate limit.

Form submission service

Перетворити purpose та submission context у Communication context.

Communication

Володіти Conversation, Message, source metadata та подальшим workflow.

Висновок

Dynamic Forms у ICanUp стали для мене хорошим прикладом того, що функція багаторазового використання не обов’язково має володіти всім своїм життєвим циклом.

Form володіє definition і public submission contract. Вона знає, як перевірити відповіді і яка бізнес мета має звернення. Але після успішного submit немає сенсу створювати ще один окремий communication domain.

Submission отримує правильний source context і переходить у вже існуючу модель Conversation + Message.

У результаті Contact, Service Inquiry, Support та майбутні форми можуть мати різні поля й різний мету, але користуватися одним перевіреним Communication pipeline.