Форма Контакти, форма Запиту послуги і форма Підтримки зовні можуть виглядати по-різному. Але якщо для кожної з них створювати окремий controller, окремий submission model і окремий workflow, дуже швидко з’являються три версії майже однакової логіки.
У ICanUp я пішла іншим шляхом. Форма є окремою динамічною сутністю з визначенням збереженого поля. Сторінка лише показує потрібну Форму через один з контент блоків. Після сабміту усі форми проходять через один public pipeline, а результатом стає звичайна Conversation і перший Message у вже існуючому Communication Center.
Тобто сторінка не володіє логікою звернення. Вона лише є одним із місць, де багаторазова форма може бути показана.
Контент блок Форма не є окремою 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.
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.
if ( $this->filled('website') || array_diff( array_keys( $this->all() ), [ 'answers', 'website', 'source', ], ) !== []) { // Reject before a Communication // conversation can be created.}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.
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
Type відповідає за поведінку Conversation, а source пояснює, звідки вона з’явилася.
Source context зберігає зв’язок із місцем відправлення
Conversation має знати не тільки "це feedback". Для адміністратора важливо бачити, з якої Form і з якої Page прийшло звернення, якою була locale і який local path відкрив користувач.
Для цього Form pipeline створює ThreadSourceContextData. Суб'єктом є сама Форма, а додатково можуть зберігатися origin Page, locale і source path.
Так Communication не залежить від конкретного Vue component, але все одно має достатній контекст, щоб правильно показати походження звернення.
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.
return [ 'source' => $this->sourceContext->source, 'subject_type' => $subject?->getMorphClass(), 'subject_id' => $subject?->getKey(), 'origin_page_id' => $this ->sourceContext ->originPage ?->getKey(), 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 та іншою логікою, яка вже є для повідомлень.
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.
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.



