The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftBuilt Ins

Form Component

Render Symfony form types via the API, handle validation field-by-field, and process submissions with FormSuccessEvent.

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"
}
FieldTypeDescription
formTypestringFully-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:

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: onInput debounces 300 ms then PATCHes the field value to the submit endpoint. The API returns updated vars with per-field errors; errors and valid update reactively.
  • POST formsvalidate() is a no-op; errors only surface on full submission.
  • Error displaydisplayErrors is false until 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.