0% прочитано

Laravel Dynamic Forms to Conversations: Reusing One Communication Pipeline

How one reusable ICanUp Form passes public validation and anti-spam, gains source context, and becomes a Conversation with an initial Message inside one Communication Center instead of separate ContactFormController, SupportFormController, and InquiryFormController flows.

26 вересня 2026 р. 9 хв читанняLaravel

A Contact form, a service inquiry form, and a Support form can look different to the user. But if each one gets its own controller, submission model, and workflow, the application quickly accumulates several versions of almost identical logic.

In ICanUp I chose a different direction. Form is a dynamic entity with a persisted field definition. A page only renders the selected Form through a ContentBlock. After submission, every Form passes through the same public pipeline and becomes a normal Conversation with an initial Message inside the existing Communication Center.

The page therefore does not own the inquiry workflow. It is only one place where a reusable Form can be displayed.

This article is not about localized validation errors. The main subject is how one Form architecture reuses the existing Communication architecture.

A Form ContentBlock Is Not a Separate Contact Form Flow

The ContentBlock knows which Form to render. The field structure, options, required state, labels, and validation rules belong to the Form itself.

The same Form can therefore be embedded on different Pages without duplicating the backend workflow. When the persisted definition changes, public submission is validated against the current definition.

This creates an important boundary: Page owns content composition, Form owns input structure, and Communication owns the lifecycle of the resulting inquiry.

Public Submission First Passes Through a Validation Boundary

The public endpoint does not accept arbitrary JSON and pass it directly into Communication.

Answers are validated against the current persisted field definition. Unexpected answer keys are rejected. A selected option must belong to this Form. An inactive, deleted, or missing Form cannot accept a submission.

The source context is validated separately. The public client may provide only a safe local path without a host, query string, or 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 Runs Before a Conversation Is Created

An invalid or obviously automated request should not leave a Thread or Message behind.

The submission flow therefore has several inexpensive checks before entering Communication: the website honeypot, a whitelist of root keys, and rate limiting.

The tests cover the honeypot, unsafe source paths, unexpected keys, and rate limiting. Another regression test ensures that invalid submission creates no 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()

The best place for anti-spam is before an external request becomes an internal business entity.

Purpose Defines Behavior Instead of a Separate Controller

A Form has a purpose. That value determines the Conversation type and source that should be created.

Project inquiry and service inquiry both use feedback behavior but retain different source context. Support uses support behavior and the support source.

I therefore do not need separate ProjectInquiryController, ServiceInquiryController, and SupportFormController implementations merely to route payloads into different parts of the 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 defines Conversation behavior, while source explains where the Conversation originated.

Source Context Preserves Where the Submission Came From

A Conversation needs more context than simply "this is feedback". An administrator may need to know which Form and Page produced it, which locale was active, and which local path the user was viewing.

The Form pipeline therefore creates ThreadSourceContextData. The Form itself becomes the subject, while the origin Page, locale, and source path can also be preserved.

Communication stays independent from the specific Vue component while retaining enough context to explain where the inquiry originated.

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(),    ),

The Conversation Stores Source Context as Its Own Attributes

ThreadContextData converts the source context into attributes on the Communication Thread itself: source, subject_type, subject_id, origin_page_id, source_locale, and source_path.

This matters for the long-term lifecycle of the inquiry. The Conversation no longer depends on the original request object and does not need to reconstruct its origin from a URL or Message text.

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',

A Valid Submission Becomes a Conversation and an Initial Message

After validation, the Form submission is not stored as an isolated "form response" that requires a second Admin workflow.

It enters the normal Communication model immediately: a Conversation is created together with its initial Message.

The inquiry can then use the capabilities already available in the Communication Center: Conversation status, replies, participants, source presentation, and the rest of the existing messaging workflow.

The tests verify an important detail: every valid Form submission creates a separate Conversation and Message. It is not one thread accidentally reused for every submission from the same Form.

Guest and Authenticated Submissions Use the Same Pipeline

For an authenticated user the system already has stable user identity. A guest Form can instead define explicit identity fields needed for the inquiry.

This does not require two separate backend flows. The difference belongs inside the submission data and Communication context rather than separate controllers.

The test suite covers guest submission, authenticated submission, configured guest identity, and association of an authenticated submission with its Message and Conversation.

Service Inquiry and Support Still Use One Form Mechanism

Different business meaning does not automatically require a different transport pipeline.

A service inquiry uses the service_inquiry source with feedback behavior. Support uses the support source and type. Until that mapping point, both use the same Form endpoint, persisted validation, and anti-spam boundary.

The difference appears exactly where it matters: when Communication context is created.

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');

Why I Did Not Build a Separate Submissions Inbox

I could have created a form_submissions table, a separate Admin list, a separate detail page, and another Reply action.

But that would effectively create a second Communication Center next to the one that already exists.

Instead, Form is responsible only for converting valid public input into the correct Communication context and initial Message. Ownership then moves to Communication.

This removes special-case logic and also makes future Form use cases cheaper: a new page or form does not require another inbox.

Reuse here is not a shared helper. It means different entry points converge on one canonical Communication model.

What Each Layer Owns

Layer

Responsibility

ContentBlock / Page

Select and render the required Form inside page content.

Form

Own fields, options, labels, purpose, and the validation contract.

Public Form submission

Validate answers, source context, honeypot, and rate limits.

Form submission service

Convert Form purpose and submission context into Communication context.

Communication

Own the Conversation, Message, source metadata, and subsequent workflow.

Conclusion

Dynamic Forms in ICanUp became a useful example of how a reusable feature does not need to own its entire lifecycle.

Form owns the definition and public submission contract. It knows how answers are validated and what business purpose the inquiry represents. But after a successful submit there is no reason to create another communication domain.

The submission receives the correct source context and moves into the existing Conversation + Message model.

Contact, Service Inquiry, Support, and future Forms can therefore have different fields and purposes while sharing one tested Communication pipeline.