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 { toRef } from 'vue'
import type { IriProp } from '#cwa/composables/cwa-resource'
import { useCwaComponent, useCwaForm, useCwaFormInput } from '#imports'
const props = defineProps<IriProp>()
const { exposeMeta } = useCwaComponent(props)
defineExpose(exposeMeta)
// the form composables take the IRI as a ref
const iriRef = toRef(props, 'iri')
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 errors and never overlap; see the error display contract.
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.
When the field has a placeholder, Symfony sends it twice: as vars.placeholder (the label text) and as an extra { value: '', label: '…' } entry in vars.choices. Filter the empty-value entry out of :items and pass vars.placeholder to :placeholder instead — left in the list it renders as a real, selectable option, and Symfony 422s on the empty value.
<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 || []).filter(c => c.value !== '')"
:placeholder="subject.vars.value?.placeholder"
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 || []).filter(c => c.value !== '')"
:placeholder="tags.vars.value?.placeholder"
:multiple="true"
value-key="value"
label-key="label"
@update:model-value="tags.onInput()"
/>
</UFormField>
Same placeholder handling as the single select above. The expanded variants (radio and checkbox group) take no placeholder — leave their :items unfiltered.
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. useCwaFormRepeated explains when errors show and where a mismatch error appears.
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)
// Spinner while the validation PATCH is in flight, tick once the entry is valid
const trailingIcon = computed(() => {
if (field.validating.value) return 'i-lucide-loader-circle'
if (field.valid.value === true) return 'i-lucide-circle-check'
return undefined
})
const trailingIconClass = computed(() => {
if (field.validating.value) return 'animate-spin text-gray-400'
if (field.valid.value === true) return 'text-green-500'
return undefined
})
</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"
:trailing-icon="trailingIcon"
:ui="{ trailingIcon: trailingIconClass }"
@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 API's prototype entry, so the new child's vars (label, errors) are there straight away. useCwaFormCollection covers how entries are numbered and removed.
Error Display Contract
Render each field's errors next to it (gated by displayErrors), and formErrors and unregisteredFieldErrors at the top of the form. useCwaForm explains what each one covers.
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
Each field validates itself against the API as the user types, and every request carries all the form's current values, so sibling errors update together. useCwaFormInput describes the request and when errors are shown.