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.
Component Helpers

Working with Forms

Build form components for Symfony Form types using CWA's form composables — covering every field type with Nuxt UI examples.

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:

ComposableUse for
useCwaFormForm-level submit, success, and error state
useCwaFormInputAny single field (TextType, EmailType, ChoiceType, CheckboxType, etc.)
useCwaFormRepeatedRepeatedType (e.g. password + confirmation)
useCwaFormCollectionCollectionType (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 nullfalse; 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 sourceWhat it coversWhere to render
useCwaFormInput.errors (gated by displayErrors)Errors for that registered fieldNext to the field in the template
useCwaForm.formErrorsRoot-level form messages (CSRF, global constraints)At the top of the form
useCwaForm.unregisteredFieldErrorsErrors for API fields not bound by any useCwaFormInputFallback 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 same full_name as the parent field. Pass the parent full_name to useCwaFormInput; do not pass individual child names.
  • Non-expanded multi-select (expanded: false, multiple: true) — Symfony appends [] to full_name in 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.