Form Component
The Form component serialises any Symfony FormType into a JSON structure the front-end can render. No custom API endpoints, no front-end form library required — just a FormType, a Form resource, and a Vue component to render the fields.
Enabling
Enabled by default. To disable:
silverback_api_components:
enabled_components:
form: false
Step 1: Create a Symfony FormType
Standard Symfony — no special base class or interface needed:
// src/Form/ContactType.php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class ContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class)
->add('email', EmailType::class)
->add('message', TextareaType::class);
}
}
Step 2: Create a Form Resource
POST /component/forms:
{
"formType": "App\\Form\\ContactType"
}
| Field | Type | Description |
|---|---|---|
formType | string | Fully-qualified class name of your FormType |
The formType field is validated by FormTypeClass constraint — it must be a registered Symfony form type.
The formView Response
The formView property is populated by FormStateProvider and contains the serialized form tree:
{
"@id": "/component/forms/018e-...",
"formType": "App\\Form\\ContactType",
"formView": {
"vars": {
"valid": true,
"submitted": false,
"errors": [],
"method": "POST",
"attr": {}
},
"children": {
"name": {
"vars": {
"name": "name",
"full_name": "contact[name]",
"id": "contact_name",
"value": "",
"required": true,
"errors": [],
"block_prefixes": ["form", "text", "_contact_name"]
}
},
"email": { "vars": { /* ... */ } },
"message": { "vars": { /* ... */ } }
}
}
}
block_prefixes tells the front-end which field type to render — text, email, textarea, etc.
Submitting the Form
Two submission modes exist at different endpoints:
Field validation (PATCH) — validate individual fields without full submission:
PATCH /component/forms/{id}/submit
Content-Type: application/merge-patch+json
{ "contact": { "name": "Alice" } }
Returns the updated formView with per-field errors. HTTP 200 = valid, 422 = invalid.
Full submission (POST) — submit all fields:
POST /component/forms/{id}/submit
Content-Type: application/json
{ "contact": { "name": "Alice", "email": "alice@example.com", "message": "Hello!" } }
HTTP 201 = success (fires FormSuccessEvent), 422 = validation failed with formView errors.
Submission Endpoint
Form data is only accepted when the request path ends with /submit. FormViewFactory sets vars.action in the form view to the absolute submit URL automatically — consuming composables read this value directly, so you never need to hardcode the endpoint in your front-end code.
The @id in the submit response is the canonical Form IRI (e.g. /component/forms/{uuid}), not the submit URL — the /submit suffix is stripped before the response is returned.
For public-facing forms (e.g. contact forms), the submit path must be explicitly allowed in security.yaml:
access_control:
- { path: '^/component/forms/.*/submit$', methods: ['POST', 'PATCH'], roles: PUBLIC_ACCESS }
Remove or restrict this rule if the form should only be accessible to authenticated users.
Handling Success on the Back-End
FormSuccessEvent is dispatched on a successful submission. The simplest approach is EntityPersistFormListener — extend it to automatically persist the form data:
// src/EventListener/Form/ContactFormListener.php
namespace App\EventListener\Form;
use App\Form\ContactType;
use App\Entity\Contact;
use Silverback\ApiComponentsBundle\EventListener\Form\EntityPersistFormListener;
class ContactFormListener extends EntityPersistFormListener
{
public function __construct()
{
parent::__construct(ContactType::class, Contact::class, true);
}
}
Register it as a subscriber:
# config/services.yaml
App\EventListener\Form\ContactFormListener:
tags:
- { name: kernel.event_subscriber }
For custom logic — sending emails, calling external APIs — listen to FormSuccessEvent directly:
use Silverback\ApiComponentsBundle\Event\FormSuccessEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ContactSuccessListener implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [FormSuccessEvent::class => 'onSuccess'];
}
public function onSuccess(FormSuccessEvent $event): void
{
$form = $event->getForm(); // the Form component resource
$data = $event->getFormData(); // the submitted data object
// Send email, create records, etc.
// Optional: set a result to serialize back to the client
$event->result = ['status' => 'sent'];
}
}
Security
Form submission endpoints are anonymous by default (useful for contact forms). The Flex recipe's security.yaml allows anonymous POST and PATCH to /component/forms:
access_control:
- { path: '^/component/forms/.*/submit', methods: ['POST', 'PATCH'], roles: PUBLIC_ACCESS }
Protect specific form types with a Symfony voter if some forms should be authenticated-only:
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException();
}
On the Front-End
Two composables handle the Vue side of a Form component:
useCwaForm(iri)— manages submission, tracks in-flight state, and exposes form-level errorsuseCwaFormInput(iri, fullName)— binds a single field to a reactive value, validates on input, and controls when errors are shown
Rendering a form
The top-level form component fetches the Form resource via useCwaResource and delegates field rendering to child components — one per field. Each child calls useCwaFormInput with the field's full_name:
<!-- components/forms/ContactForm.vue -->
<script setup lang="ts">
const props = defineProps<{ iri: string }>()
const { resource } = useCwaResource(props)
const iriRef = toRef(props, 'iri')
const { submit, submitting, success, formErrors } = useCwaForm(iriRef)
const fields = computed(() => resource.value?.formView ?? {})
</script>
<template>
<form @submit.prevent="submit">
<ContactFormField
v-for="(field, fullName) in fields"
:key="fullName"
:iri="props.iri"
:full-name="fullName"
/>
<p v-for="error in formErrors" :key="error" class="text-red-500">{{ error }}</p>
<button type="submit" :disabled="submitting">
{{ submitting ? 'Sending…' : 'Submit' }}
</button>
<p v-if="success">Sent!</p>
</form>
</template>
<!-- components/forms/ContactFormField.vue -->
<script setup lang="ts">
const props = defineProps<{ iri: string; fullName: string }>()
const { value, vars, errors, displayErrors, onBlur, onInput } = useCwaFormInput(
toRef(props, 'iri'),
props.fullName
)
</script>
<template>
<div>
<label :for="props.fullName">{{ vars?.label }}</label>
<input :id="props.fullName" v-model="value" v-bind="vars?.attr" @blur="onBlur" @input="onInput" />
<ul v-if="displayErrors && errors.length">
<li v-for="error in errors" :key="error" class="text-red-500">{{ error }}</li>
</ul>
</div>
</template>
Validation behaviour
- PATCH forms — each field validates in real time:
onInputdebounces 300 ms then PATCHes the field value to the submit endpoint. The API returns updatedvarswith per-field errors;errorsandvalidupdate reactively. - POST forms —
validate()is a no-op; errors only surface on full submission. - Error display —
displayErrorsisfalseuntil the user blurs the field, the field regresses from valid, or the form is submitted and fails. This prevents error flash on first render.
Request body format
Both PATCH validation and POST/PATCH submission use Symfony full_name keys:
{ "contact_form[name]": "Alice", "contact_form[email]": "alice@example.com" }
The root key (contact_form) is derived by stripping [...] from the first full_name.