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



