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 built by FormApiEventListener (via FormViewFactory) when the resource is read, and rebuilt from the submitted data on every submit. It contains the serialized form tree:

{
    "@id": "/component/forms/018e-...",
    "formType": "App\\Form\\ContactType",
    "formView": {
        "vars": {
            "valid": true,
            "submitted": false,
            "errors": [],
            "action": "https://api.example.com/_api/component/forms/018e-.../submit",
            "attr": {}
        },
        "children": [
            {
                "vars": {
                    "name": "name",
                    "full_name": "contact[name]",
                    "id": "contact_name",
                    "value": "",
                    "required": true,
                    "errors": [],
                    "block_prefixes": ["form", "text", "_contact_name"]
                }
            },
            { "vars": { "name": "email", "full_name": "contact[email]" /* ... */ } },
            { "vars": { "name": "message", "full_name": "contact[message]" /* ... */ } }
        ],
        "rendered": false,
        "methodRendered": false
    }
}

children is an array, not an object keyed by field name — identify each child by its vars.full_name.

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, above the catch-all rule that requires authentication for write methods:

access_control:
    - { path: ^/_api/component/forms/(.*)/submit, roles: PUBLIC_ACCESS, methods: [POST, PATCH] }    - { path: ^/, roles: IS_AUTHENTICATED_FULLY, methods: [POST, PUT, PATCH, DELETE] }
The ^/_api prefix matters. access_control paths are matched against the full request path, so a pattern without your API Platform routing prefix matches nothing and every anonymous submission falls through to the catch-all rule and is refused. /_api is the prefix set in config/routes/api_platform.yaml in the CWA template — adjust if yours differs.

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 child of the bundle's abstract service:

# config/services.yaml
App\EventListener\Form\ContactFormListener:
    parent: Silverback\ApiComponentsBundle\EventListener\Form\EntityPersistFormListener
The parent: is not optional. The bundle's abstract EntityPersistFormListener service is what calls init() to inject the entity manager registry, timestamp persister, normalizer, and user services. Register it without the parent and every one of those is null, so the listener fatals on the first submission.It is not a kernel.event_subscriberEntityPersistFormListener implements FormSuccessEventListenerInterface (a single __invoke), so that tag makes the container throw at compile time. The bundle autoconfigures the correct kernel.event_listener tag for FormSuccessEvent; only add it by hand if your app turns autoconfiguration off.

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 is anonymous by default (useful for contact forms) via the access_control rule shown under Submission Endpoint.

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:

The form container calls useCwaForm once for the submit lifecycle, then one useCwaFormInput per field, addressing each by its full_name (contact[name]). See Working with Forms for the full component, every Symfony field type, and Nuxt UI examples.

Validation behaviour

  • Real-time validationonInput debounces 300 ms and then PATCHes the current field values to the submit endpoint. This happens for every form, regardless of whether it is finally submitted with POST or PATCH; the submit method only affects useCwaForm().submit(). The API returns updated vars with per-field errors; errors and valid update reactively.
  • 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.

Field names vs request body

CWA composables address fields by their Symfony full_name (contact[name]), but the HTTP body is nested under the form's name. The module converts one to the other before sending, so what goes over the wire is:

{ "contact": { "name": "Alice", "email": "alice@example.com" } }
A body whose root key is not the form name is rejected with 422 Form object key could not be found. If you call the submit endpoint directly rather than through the composables, send the nested shape — not flat full_name keys.