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 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] }
^/_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\EntityPersistFormListenerparent: 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_subscriber — EntityPersistFormListener 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:
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
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 validation —
onInputdebounces 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 affectsuseCwaForm().submit(). The API returns updatedvarswith per-field errors;errorsandvalidupdate reactively. - 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.
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" } }
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.