Working with Forms
CWA renders Symfony forms as API resources. The form's field structure (vars, choices, errors, constraints) comes from the API response; CWA composables wire that data to reactive Vue state and handle submit/validation automatically.
Four composables cover the full Symfony form type system:
| Composable | Use for |
|---|---|
useCwaForm | Form-level submit, success, and error state |
useCwaFormInput | Any single field (TextType, EmailType, ChoiceType, CheckboxType, etc.) |
useCwaFormRepeated | RepeatedType (e.g. password + confirmation) |
useCwaFormCollection | CollectionType (dynamic add/remove entries) |
Form container component
The root CWA component for a form calls useCwaForm for submit lifecycle and one useCwaFormInput (or useCwaFormRepeated / useCwaFormCollection) per field:
<script setup lang="ts">
import { computed, toRef } from 'vue'
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaResource, useCwaForm, useCwaFormInput } from '#imports'
const props = defineProps<IriProp>()
const iriRef = toRef(props, 'iri')
const { exposeMeta } = useCwaResource(iriRef)
defineExpose(exposeMeta)
const form = useCwaForm(iriRef)
const name = useCwaFormInput(iriRef, 'contact_form[name]')
const email = useCwaFormInput(iriRef, 'contact_form[email]')
const message = useCwaFormInput(iriRef, 'contact_form[message]')
</script>
<template>
<form @submit.prevent="form.submit()">
<!-- field slots here -->
<UAlert v-if="form.success.value" color="success" title="Sent!" />
<UAlert
v-for="error in form.formErrors.value"
:key="error"
color="error"
:description="error"
/>
<UAlert
v-for="error in form.unregisteredFieldErrors.value"
:key="error"
color="warning"
:description="error"
/>
<UButton type="submit" :loading="form.submitting.value" :disabled="form.success.value">
Submit
</UButton>
</form>
</template>
Always render both formErrors and unregisteredFieldErrors — they cover different error categories and never overlap. See Error display contract below.
Field types
Text, Email, Textarea
<!-- TextType / EmailType -->
<UFormField
:label="name.vars.value?.label"
:error="name.displayErrors.value ? name.errors.value[0] : undefined"
:required="name.vars.value?.required"
>
<UInput
v-model="name.value.value"
@blur="name.onBlur"
@input="name.onInput"
/>
</UFormField>
<!-- TextareaType -->
<UFormField
:label="message.vars.value?.label"
:error="message.displayErrors.value ? message.errors.value[0] : undefined"
>
<UTextarea
v-model="message.value.value"
@blur="message.onBlur"
@input="message.onInput"
/>
</UFormField>
ChoiceType — select (collapsed, single)
vars.choices is an array of { label, value } objects from the API. Always pass value-key and label-key — without them, Nuxt UI binds the full object to v-model and Symfony rejects it with a 422 "option selected is invalid" error.
<UFormField
:label="subject.vars.value?.label"
:error="subject.displayErrors.value ? subject.errors.value[0] : undefined"
>
<USelect
v-model="subject.value.value"
:items="subject.vars.value?.choices || []"
value-key="value"
label-key="label"
@update:model-value="subject.onInput()"
@blur="subject.onBlur"
/>
</UFormField>
ChoiceType — radio group (expanded, single)
<UFormField
:label="plan.vars.value?.label"
:error="plan.displayErrors.value ? plan.errors.value[0] : undefined"
>
<URadioGroup
v-model="plan.value.value"
:items="plan.vars.value?.choices || []"
value-key="value"
label-key="label"
@change="plan.onInput()"
/>
</UFormField>
ChoiceType — checkbox group (expanded, multiple)
When vars.multiple is true, the field value is a string[].
<UFormField
:label="interests.vars.value?.label"
:error="interests.displayErrors.value ? interests.errors.value[0] : undefined"
>
<UCheckboxGroup
v-model="interests.value.value"
:items="interests.vars.value?.choices || []"
value-key="value"
label-key="label"
@change="interests.onInput()"
/>
</UFormField>
ChoiceType — multi-select (collapsed, multiple)
<UFormField
:label="tags.vars.value?.label"
:error="tags.displayErrors.value ? tags.errors.value[0] : undefined"
>
<USelectMenu
v-model="tags.value.value"
:items="tags.vars.value?.choices || []"
:multiple="true"
value-key="value"
label-key="label"
@update:model-value="tags.onInput()"
/>
</UFormField>
CheckboxType (single checkbox)
Symfony's CheckboxType submits '1' when checked and null when unchecked. useCwaFormInput initialises value from vars.checked ('1' or null), so read the boolean state from !!field.value.value. Always set the unchecked value to null — Symfony's BooleanToStringTransformer maps null → false; an empty string '' is treated as true and suppresses NotBlank/IsTrue constraint errors:
<script setup lang="ts">
const agree = useCwaFormInput(iriRef, 'form[agreeToTerms]')
const isChecked = computed({
get: () => !!agree.value.value,
set: (v: boolean) => {
agree.value.value = v ? '1' : null
agree.onInput()
},
})
</script>
<template>
<UCheckbox
v-model="isChecked"
:label="agree.vars.value?.label"
@blur="agree.onBlur"
/>
<p v-if="agree.displayErrors.value" class="text-red-500">
{{ agree.errors.value[0] }}
</p>
</template>
RepeatedType (password + confirmation)
<script setup lang="ts">
const password = useCwaFormRepeated(iriRef, 'registration[plainPassword]')
</script>
<template>
<UFormField
:label="password.first.vars.value?.label || 'New password'"
:error="password.first.displayErrors.value ? password.first.errors.value[0] : undefined"
>
<UInput
v-model="password.first.value.value"
type="password"
autocomplete="new-password"
@blur="password.first.onBlur"
@input="password.first.onInput"
/>
</UFormField>
<UFormField
:label="password.second.vars.value?.label || 'Confirm password'"
:error="password.second.displayErrors.value ? password.second.errors.value[0] : undefined"
>
<UInput
v-model="password.second.value.value"
type="password"
autocomplete="new-password"
@blur="password.second.onBlur"
@input="password.second.onInput"
/>
</UFormField>
</template>
Each side validates with the sibling's current value so the API can check both entries match.
CollectionType
Use useCwaFormCollection in the parent and useCwaFormInput in a child component per entry. The child component's lifecycle automatically registers and unregisters field values.
<!-- Parent component -->
<script setup lang="ts">
const tags = useCwaFormCollection(iriRef, 'example_form[tags]')
</script>
<template>
<TagEntry
v-for="entry in tags.entries.value"
:key="entry"
:iri="props.iri"
:entry-full-name="entry"
@remove="tags.removeEntry(entry)"
/>
<p v-if="tags.vars.value?.errors?.[0]" class="text-red-500">
{{ tags.vars.value.errors[0] }}
</p>
<UButton
v-if="tags.vars.value?.allow_add"
variant="soft"
@click.prevent="tags.addEntry()"
>
Add Tag
</UButton>
</template>
<!-- TagEntry.vue — child component -->
<script setup lang="ts">
const props = defineProps<{ iri: string | undefined, entryFullName: string }>()
defineEmits<{ remove: [] }>()
const field = useCwaFormInput(toRef(props, 'iri'), props.entryFullName)
</script>
<template>
<UFormField :error="field.displayErrors.value ? field.errors.value[0] : undefined">
<div class="flex items-center gap-2">
<UInput v-model="field.value.value" class="flex-1" @blur="field.onBlur" @input="field.onInput" />
<UButton color="error" variant="soft" @click="$emit('remove')">Remove</UButton>
</div>
</UFormField>
</template>
For compound entries (each entry has multiple sub-fields), call useCwaFormInput(iriRef, \${props.entryFullName}subField`)` for each sub-field within the child component.
addEntry() clones the prototype entry from the API form view and pre-registers it in the form store, so vars (label, errors, etc.) is available immediately in the child component without waiting for a validation or submission response.Error display contract
| Error source | What it covers | Where to render |
|---|---|---|
useCwaFormInput.errors (gated by displayErrors) | Errors for that registered field | Next to the field in the template |
useCwaForm.formErrors | Root-level form messages (CSRF, global constraints) | At the top of the form |
useCwaForm.unregisteredFieldErrors | Errors for API fields not bound by any useCwaFormInput | Fallback block near formErrors |
unregisteredFieldErrors exists to prevent silent error loss — if the API returns an error on a field your template didn't bind, it surfaces here instead of disappearing. The composable filters by registered keys, so there is no duplication with field-level errors.
full_name convention
The fullName argument to useCwaFormInput must match the full_name value in the API's form view, which follows Symfony's bracket notation:
form_name[fieldName]
form_name[parent][child]
form_name[collection][0][subField]
The root key (no brackets, e.g. form_name) is reserved for the form root and should not be passed to useCwaFormInput.
Two ChoiceType configurations require special attention:
- Expanded radio groups (
expanded: true, multiple: false) — each radio button child shares the samefull_nameas the parent field. Pass the parentfull_nametouseCwaFormInput; do not pass individual child names. - Non-expanded multi-select (
expanded: false, multiple: true) — Symfony appends[]tofull_namein the raw form view (e.g.example_form[interests][]). The composable strips this suffix automatically, so pass the bare key without[](e.g.example_form[interests]).
Validation internals
Per-field validation sends a PATCH to {iri}/submit and the response @id is normalised back to the Form IRI before saving to the store. This means errors and valid on each useCwaFormInput update reactively after every keystroke without any extra wiring.
Each PATCH body includes all currently registered field values — not just the triggering field — so the API can validate the whole form and all sibling field errors update in a single response.
displayErrors is suppressed while a validation PATCH is in-flight (validating: true). This prevents stale errors from flashing briefly between submitting a value and receiving the updated form view.