# Forms

A guide to building forms with Fluid Primitives — covering AJAX submission, Extbase integration, client-side validation, and field state management.

## Overview

The Form component replaces TYPO3's `f:form` ViewHelper with an AJAX-first alternative. Instead of a full-page reload, it submits via `fetch`, handles server-side Extbase validation errors, and updates field state without reloading the page.

**What it gives you:**

- AJAX submission — no full-page reload
- Automatic Extbase field name prefixing (`tx_myext[MyObject][field]`)
- 422 error mapping from Extbase validation to individual fields
- Optional client-side validation with Standard Schema-compatible validators or callbacks
- Form state (`ready`, `submitting`, `invalid`, `success`, `error`) exposed as `data-state` for CSS
- Field-level error display, label association, and ARIA wiring via the Field component
- Works with all Field-aware primitives: Select, Checkbox, RadioGroup, NumberInput, and plain HTML inputs

## Installation

First you should add the Form and Field primitives to your project:

```bash
typo3 ui:add form && typo3 ui:add field
```

## Basic Setup

### Template

Use `ui:form` with `action` pointing to your Extbase action and `objectName` matching the argument name in your controller:

```html
<ui:form.root
    action="registration"
    objectName="eventRegistration"
    object="{eventRegistration}"
    controlled="{true}"
    rootId="registration-form"
>
    <ui:field.root name="email" required="{true}">
        <ui:input.root type="email" autocomplete="email">
            <ui:input.label>Email</ui:input.label>
            <ui:input.input />
        </ui:input.root>
        <ui:field.description>Used for your confirmation email.</ui:field.description>
        <ui:field.error />
    </ui:field.root>

    <ui:button type="submit">Register</ui:button>
</ui:form.root>
```

The form renders as a standard `<form>` with `novalidate` and the Extbase action URL resolved server-side. The `object` prop pre-populates field values from an existing model instance.

### Controller

```php
<?php

declare(strict_types=1);

namespace Vendor\MyExtension\Controller;

use Vendor\MyExtension\Domain\Model\EventRegistration;
use Jramke\FluidPrimitives\Traits\AjaxValidationTrait;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

final class EventRegistrationController extends ActionController
{
    use AjaxValidationTrait;

    public function registrationAction(EventRegistration $eventRegistration): ResponseInterface
    {
        // Process the submission
        // $this->eventRegistrationRepository->save($eventRegistration);

        return $this->jsonResponse(json_encode(['success' => true]))->withStatus(200);
    }

    protected function errorAction(): ResponseInterface
    {
        // Converts Extbase validation errors to a 422 JSON response
        $this->throwJsonValidationErrorResponse();
        return parent::errorAction();
    }
}
```

The `AjaxValidationTrait` provides `throwJsonValidationErrorResponse()`, which intercepts Extbase's normal `errorAction` redirect and instead returns a 422 JSON response with field-keyed error messages. The Form component reads this response and assigns errors to individual fields.

<div class="relative w-full rounded-lg border px-4 py-3 text-sm grid has-[&gt;svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[&gt;svg]:gap-x-3 gap-y-0.5 items-start [&amp;&gt;svg]:size-4 [&amp;&gt;svg]:translate-y-0.5 [&amp;&gt;svg]:text-current bg-card text-card-foreground not-prose" role="alert" id="alert:«fFP3aqMG22»" data-scope="alert" data-part="root" >

<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5"> <circle cx="12" cy="12" r="10"></circle> <path d="M12 16v-4"></path> <path d="M12 8h.01"></path> </svg>

<div class="col-start-2 min-h-4 font-medium tracking-tight" id="alert:«fFP3aqMG22»:title" data-scope="alert" data-part="title" >

<h3>Pro Tip</h3>

</div>

<div class="text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&amp;_p]:leading-relaxed" id="alert:«fFP3aqMG22»:content" data-scope="alert" data-part="content" >

<p>If your controller action is not registered as a standalone Plugin you can use <code>throw new PropagateResponseException</code> to return a plain json response.</p>

</div>

</div>

### Entry File (TypeScript)

The form requires a client-side entry file. Use `controlled="{true}"` on the root and fetch its hydration data by ID:

```typescript
import { mount } from 'fluid-primitives';
import { Form } from 'fluid-primitives/form';

mount('form', 'registration-form', ({ props }) => {
    const form = new Form({
        ...props,
        onSubmit: async ({ api, post }) => {
            const response = await post(api.getAction());
            return response.ok;
        },
    });

    form.init();
});
```

The `post()` helper automatically adds the Extbase field name prefix (`tx_myext[MyObject][field]`) before sending, so your form fields can use plain names like `email` or nested dot paths like `person.name` in the template. It always submits the current form as `FormData`, and it also handles 422 JSON validation responses for you by mapping them back to fields and transitioning the form to `invalid`.

The Form API exposes a `FormValues` object via `api.getValues()` and inside `validation` / `onSubmit` callbacks. `FormValues` keeps leaf values uncoerced as `string | File`, supports `get(path)`, `getAll(path)`, `has(path)`, `pick(path)`, and `toObject()`, and uses your field names as canonical dot paths. That means `ticketCount` still reads as `'2'`, not `2`, until your validator or application code converts it.

## The Field Component

`ui:field.root` wraps any input and wires up labels, errors, descriptions, and ARIA attributes. The `name` prop is required and must match the property name on your model.

### Anatomy

A Field-aware primitive like `ui:input` (or `ui:select`, `ui:numberInput`, ...) nests directly inside `ui:field.root` - no `field.control` needed, it inherits `name`/`disabled`/`required`/`invalid`/`aria-describedby` automatically. Use the primitive's own `label` part (nested inside its `root`) rather than `field.label` - it targets the right control automatically, the same way `ui:numberInput.label` does:

```html
<ui:field.root name="email" required="{true}">
    <ui:input.root type="email">
        <ui:input.label>Email address</ui:input.label>
        <ui:input.input />
    </ui:input.root>
    <ui:field.description>We'll send your confirmation here.</ui:field.description>
    <ui:field.error />
</ui:field.root>
```

For a genuinely native/custom element with no dedicated primitive, use `field.control` with `asChild="{true}"` instead - it spreads the field's ARIA attributes onto the child element directly:

```html
<ui:field.root name="rating" required="{true}">
    <ui:field.label>Rating</ui:field.label>
    <ui:field.control asChild="{true}">
        <select>
            <option value="1">1 star</option>
            <option value="5">5 stars</option>
        </select>
    </ui:field.control>
    <ui:field.error />
</ui:field.root>
```

**Parts:**

- `field.label` — renders a `<label>` with `for` pointing to the control
- `field.control` — when `asChild="{true}"`, spreads the field's ARIA attributes onto the child element
- `field.description` — optional helper text, wired to `aria-describedby`
- `field.error` — renders the error message, wired to `aria-describedby` and only shown when the field is in an error state

### Field Props

`ui:field.root` accepts these props:

- `name` (`string`, required) — maps to the model property and form field name
- `required` (`boolean`) — marks the field required; propagates to the control
- `disabled` (`boolean`) — disables the field and all contained controls
- `readOnly` (`boolean`) — sets the field and controls to read-only
- `invalid` (`boolean`) — forces the field into an invalid state (e.g. pre-populated server error)
- `defaultValue` (`mixed`) — pre-populates the field value

### Inherited Field Props on Primitives

When a Field-aware primitive is placed inside a `ui:field.root`, the field's state automatically propagates into the primitive. You do not need to repeat `disabled`, `required`, etc. on the primitive itself.

```html
<!-- disabled on field.root propagates to the Select automatically -->
<ui:field.root name="country" disabled="{true}">
    <ui:select.root collection="{countries}">
        <ui:select.label>Country</ui:select.label>
        <ui:select.control>
            <ui:select.trigger placeholder="Pick a country" />
        </ui:select.control>
        <ui:select.content>
            <f:for each="{countries.items}" as="item">
                <ui:select.item item="{item}">
                    <ui:select.itemText>{item.label}</ui:select.itemText>
                </ui:select.item>
            </f:for>
        </ui:select.content>
    </ui:select.root>
    <ui:field.error />
</ui:field.root>
```

## Server-Side Validation

Each form should have server-side validation to ensure your controller gets the expected data. Since server-side validation cant be easily bypassed this is our main source of truth. Client-side validation "just" enhances the UX of your forms. You should use both.

Server-side errors are stored by its field and value. So like in the example, when the user submits a VIP registration he gets an error because vip tickets are sold out. When he changes the ticket type to standard the error resolves. However when the user switches back to the vip ticket we automatically show the server side error again.

### Extbase Model Validation

Use PHP 8 attributes on your model to declare validation rules. Extbase runs these before your action is called. If validation fails, `errorAction` is triggered — which the `AjaxValidationTrait` converts to a 422 JSON response.

```php
<?php

declare(strict_types=1);

namespace Vendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Annotation\Validate;

class EventRegistration
{
    #[Validate(['validator' => 'NotEmpty'])]
    #[Validate(['validator' => 'EmailAddress'])]
    #[Validate(['validator' => 'StringLength', 'options' => ['maximum' => 255]])]
    private string $email = '';

    #[Validate(['validator' => 'NotEmpty'])]
    #[Validate(['validator' => 'StringLength', 'options' => ['maximum' => 255]])]
    private string $name = '';

    #[Validate(['validator' => 'Boolean', 'options' => ['is' => true]])]
    private bool $privacy = false;

    // getters and setters...
}
```

The 422 JSON response has the shape:

```json
{
    "eventRegistration.email": ["This field must be a valid email address."],
    "eventRegistration.name": ["This field must not be empty."]
}
```

The Form component maps each key to the corresponding field by name and displays the error in `ui:field.error`.

### Manual 422 Response

For business-rule validation that doesn't belong in the model, return a 422 directly from your action:

```php
public function registrationAction(EventRegistration $eventRegistration): ResponseInterface
{
    if ($eventRegistration->getTicketType() === 'vip') {
        $payload = ['eventRegistration.ticketType' => ['VIP tickets are sold out.']];
        return $this->jsonResponse(json_encode($payload))->withStatus(422);
    }

    // continue with save...
}
```

### Submit Results And Status States

`onSubmit` can return three outcomes:

- `true` transitions the form to `success`
- `false` transitions the form to the generic `error` state
- `Record<string, { messages: string[] }>` transitions the form to `invalid` and assigns field errors

For form-level messaging, use `api.setErrorText()` and `api.setSuccessText()`. The `form.content`, `form.indicator`, `form.errorText`, and `form.successText` primitives remove the need for `group-[[data-state=...]]` selectors:

```html
<ui:form.root ...>
    <ui:form.content>
        <!-- fields -->
    </ui:form.content>

    <ui:form.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Error')}">
        <ui:form.errorText>Something went wrong. Please try again.</ui:form.errorText>
        <ui:button type="reset">Back to form</ui:button>
    </ui:form.indicator>

    <ui:form.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Success')}">
        <ui:form.successText
            >Your registration was submitted successfully. Thank you!</ui:form.successText
        >
    </ui:form.indicator>
</ui:form.root>
```

Set the status text in your entry file and return the matching submit result:

```typescript
onSubmit: async ({ api, post }) => {
    const response = await post(api.getAction());
    const json = await response.json();

    if (!response.ok) {
        api.setErrorText(json.message ?? 'Something went wrong.');
        return false;
    }

    api.setSuccessText(json.message ?? 'Your registration was submitted successfully.');
    return true;
},
```

When you use `post()`, 422 JSON validation responses do not come back as a normal `Response`. They are intercepted by the Form primitive, mapped to field errors automatically, and transition the form to `invalid`.

## Client-Side Validation

To enhance the UX of your forms you should also use (slimmed down) client-side validation in addition to (more complex) server-side validation.

Client-side validation runs on blur for dirty fields and before submission. Once a field already has an error, we validate it on change too so the user gets immediate feedback while fixing it.

Each field also tracks local interaction metadata. `field.meta.isTouched` becomes `true` after the first change or blur, `field.meta.isDirty` stays `true` once the value was changed, `field.meta.isPristine` is the inverse of `isDirty`, `field.meta.isBlurred` becomes `true` after the first blur, and `field.meta.isDefaultValue` reflects whether the current value matches the initial value. The same state is mirrored to `field.root` as `data-touched`, `data-dirty`, `data-pristine`, `data-blurred`, and `data-default-value` attributes for styling.

Install your validator separately. For Zod:

```bash
npm install zod
```

You do not need an extra Standard Schema package in your app code. Just pass your existing schema object to `validation`.

Pass a synchronous Standard Schema-compatible validator to the `Form` constructor. Zod works out of the box. Errors are mapped to fields by key:

```typescript
import { z } from 'zod';
import { Form } from 'fluid-primitives/form';

const validation = z.object({
    name: z.string().min(1, 'Please enter your name'),
    email: z.email('Please enter a valid email address'),
    ticketCount: z.coerce.number().min(1).max(10),
    privacy: z.literal('1', 'You must accept the privacy policy'),
});

const form = new Form({
    ...data.props,
    validation,
    onSubmit: async ({ api, post }) => {
        const response = await post(api.getAction());
        return response.ok;
    },
});
```

Any validator that implements the Standard Schema interface can be passed here without an adapter.

Validation keys must match the field `name` props in the template. If client-side validation fails, the form stays in the `invalid` state and focus moves to the first invalid field. The `onSubmit` callback is not called.

## Manual Client-Side Validation

If you prefer not to use a schema library, pass a synchronous `validation` callback. It receives the current `values` object and should return the same flat field-keyed error shape used by server validation responses. Missing `value` properties are filled automatically with the current field value:

```typescript
import { Form } from 'fluid-primitives/form';

const form = new Form({
    ...data.props,
    validation: ({ values }) => {
        const errors: Record<string, { messages: string[] }> = {};
        const email = values.get('email');

        if (typeof email !== 'string' || !email.includes('@')) {
            errors.email = { messages: ['Please enter a valid email address.'] };
        }

        return errors;
    },
    onSubmit: async ({ api, post }) => {
        const response = await post(api.getAction());

        return response.ok;
    },
});
```

Validation callbacks must be synchronous. Use `onSubmit` for async checks.

## Async Validation During Submission

If a validation rule depends on async work inside `onSubmit`, prefer `post()` when the endpoint accepts the same form payload shape. If that endpoint returns a non-422 response, you can still map it to field errors yourself:

```typescript
import { Form } from 'fluid-primitives/form';

const form = new Form({
    ...data.props,
    onSubmit: async ({ post }) => {
        const response = await post('/email-check');

        if (response.status === 409) {
            return {
                email: { messages: ['This email address is already registered.'] },
            };
        }

        if (!response.ok) {
            return false;
        }

        return true;
    },
});
```

Returning field errors from `onSubmit` transitions the form to `invalid` and maps errors to fields exactly like a 422 server response.

## Form State

The form element receives a `data-state` attribute that reflects the current state:

- `ready` — initial state, form is ready for input
- `submitting` — submission in progress
- `invalid` — validation failed (client or server)
- `success` — `onSubmit` returned `true`
- `error` — `onSubmit` returned `false` or threw a non-validation error

Use `data-state` in CSS to conditionally show/hide sections or style the submit button:

```css
form[data-submitting] button[type='submit'] {
    opacity: 0.5;
    pointer-events: none;
}
```

The `render` callback on the `Form` constructor runs every time the form state changes. Use it to update UI elements that are outside the form machine's automatic wiring:

```typescript
render: form => {
    const submitButton = hydrator.getElement('submit-button');
    if (submitButton) {
        submitButton.setAttribute('aria-disabled', form.api.isSubmitting ? 'true' : 'false');
        submitButton.textContent = form.api.isSubmitting ? 'Submitting...' : 'Register';
    }
},
```

## Complete Example: Event Registration

A full event registration form with Extbase model validation, a server-side business rule (VIP tickets sold out), and Zod client-side pre-validation via `validation`.

**EventRegistration.html**

```html
<ui:exposeToClient />
<ui:prop name="object" type="FluidPrimitives\Docs\Domain\Model\EventRegistration" optional="{true}" />

<ui:form.root
    action="registration"
    objectName="eventRegistration"
    object="{object}"
    controlled="{true}"
    rootId="{rootId}-form">
    <h2 class="text-lg font-semibold">Event Registration</h2>

    <ui:form.content class="space-y-6">

        <ui:listCollection
            items="{
                0: {value: 'vip', label: 'VIP'},
                1: {value: 'standard', label: 'Standard'},
                2: {value: 'student', label: 'Student'}
            }"
            as="ticketTypes" />

        <ui:field.root name="ticketType" required="{true}">
            <ui:select.root collection="{ticketTypes}" class="field">
                <ui:select.label>Ticket Type</ui:select.label>
                <ui:select.control>
                    <ui:select.trigger placeholder="Select a ticket type" />
                </ui:select.control>
                <ui:select.content>
                    <f:for each="{ticketTypes.items}" as="item">
                        <ui:select.item item="{item}">
                            <ui:select.itemText>{item.label}</ui:select.itemText>
                            <ui:select.itemIndicator />
                        </ui:select.item>
                    </f:for>
                </ui:select.content>
            </ui:select.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="studentId" required="{true}" hidden="{true}" disabled="{true}">
            <ui:input.root>
                <ui:input.label>Student ID</ui:input.label>
                <ui:input.input />
            </ui:input.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="ticketCount" required="{true}">
            <ui:numberInput.root min="1" max="10">
                <ui:numberInput.scrubber>
                    <ui:numberInput.label>How many tickets?</ui:numberInput.label>
                </ui:numberInput.scrubber>
                <ui:numberInput.control>
                    <ui:numberInput.decrementTrigger />
                    <ui:numberInput.input />
                    <ui:numberInput.incrementTrigger />
                </ui:numberInput.control>
            </ui:numberInput.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="mode" required="{true}">
            <ui:radioGroup.root>
                <ui:radioGroup.label>Attendance Mode</ui:radioGroup.label>
                <ui:radioGroup.item value="person">
                    <ui:radioGroup.itemControl />
                    <ui:radioGroup.itemText>In Person</ui:radioGroup.itemText>
                </ui:radioGroup.item>
                <ui:radioGroup.item value="virtual">
                    <ui:radioGroup.itemControl />
                    <ui:radioGroup.itemText>Virtual</ui:radioGroup.itemText>
                </ui:radioGroup.item>
            </ui:radioGroup.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="person.name" required="{true}">
            <ui:input.root autocomplete="name">
                <ui:input.label>Name</ui:input.label>
                <ui:input.input />
            </ui:input.root>
            <ui:field.description>Your full name as it will appear on the ticket.</ui:field.description>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="person.email" required="{true}">
            <ui:input.root type="email" autocomplete="email">
                <ui:input.label>Email</ui:input.label>
                <ui:input.input />
            </ui:input.root>
            <ui:field.description>Your email address will be used for registration confirmation and updates.</ui:field.description>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="person.phone">
            <ui:input.root type="tel" autocomplete="tel">
                <ui:input.label>Phone</ui:input.label>
                <ui:input.input />
            </ui:input.root>
            <ui:field.description>Only used if we need to contact you about your registration.</ui:field.description>
            <ui:field.error />
        </ui:field.root>

        <ui:listCollection
            items="{
                0: {value: 'us', label: 'United States'},
                1: {value: 'uk', label: 'United Kingdom'},
                2: {value: 'de', label: 'Germany'},
                3: {value: 'fr', label: 'France'},
                4: {value: 'nl', label: 'Netherlands'},
                5: {value: 'se', label: 'Sweden'},
                6: {value: 'no', label: 'Norway'},
                7: {value: 'dk', label: 'Denmark'},
                8: {value: 'fi', label: 'Finland'},
                9: {value: 'it', label: 'Italy'},
                10: {value: 'es', label: 'Spain'},
                11: {value: 'pt', label: 'Portugal'}
            }"
            as="countries" />

        <ui:field.root name="person.country" required="{true}">
            <ui:combobox.root collection="{countries}">
                <ui:combobox.label>Country</ui:combobox.label>
                <ui:combobox.control class="min-w-64">
                    <ui:combobox.input placeholder="Search your country" />
                    <ui:combobox.clearTrigger />
                    <ui:combobox.trigger>
                        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
                            <path d="m7 15 5 5 5-5"></path>
                            <path d="m7 9 5-5 5 5"></path>
                        </svg>
                    </ui:combobox.trigger>
                </ui:combobox.control>
                <ui:combobox.content>
                    <ui:combobox.empty>No countries found.</ui:combobox.empty>
                    <f:for each="{countries.items}" as="item">
                        <ui:combobox.item item="{item}">
                            <ui:combobox.itemText>{item.label}</ui:combobox.itemText>
                            <ui:combobox.itemIndicator />
                        </ui:combobox.item>
                    </f:for>
                </ui:combobox.content>
            </ui:combobox.root>
            <ui:field.description>Used for your badge and invoice details.</ui:field.description>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="badgePhoto">
            <ui:fileUpload.root accept="image/jpeg,image/png" maxFiles="1">
                <ui:fileUpload.label>Badge Photo (optional)</ui:fileUpload.label>
                <ui:fileUpload.dropzone>
                    <div class="grid gap-1">
                        <div class="text-sm font-medium">Upload a photo for your badge</div>
                        <div class="text-muted-foreground text-sm">JPG or PNG, up to 5MB.</div>
                    </div>
                    <ui:fileUpload.trigger>Choose file</ui:fileUpload.trigger>
                </ui:fileUpload.dropzone>

                <primitives:fileUpload.itemTemplate>
                    <ui:fileUpload.fileItem>
                        <ui:fileUpload.itemPreview match="image/*">
                            <ui:fileUpload.itemPreviewImage />
                        </ui:fileUpload.itemPreview>
                        <ui:fileUpload.itemPreview match=".*">
                            <ui:fileUpload.filePreviewFallback />
                        </ui:fileUpload.itemPreview>
                        <div class="grid min-w-0 flex-1 gap-0.5">
                            <ui:fileUpload.fileName />
                            <ui:fileUpload.fileMeta />
                            <ui:fileUpload.fileError />
                        </div>
                        <ui:fileUpload.fileDeleteTrigger />
                    </ui:fileUpload.fileItem>
                </primitives:fileUpload.itemTemplate>

                <ui:fileUpload.itemGroup>
                    <ui:fileUpload.emptyState>No file selected yet.</ui:fileUpload.emptyState>
                </ui:fileUpload.itemGroup>
            </ui:fileUpload.root>
            <ui:field.description>Shown on your badge if provided. You can skip this.</ui:field.description>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="a11yNeeds[]">
            <ui:checkboxGroup.root>
                <ui:checkboxGroup.label>Accessibility Needs</ui:checkboxGroup.label>
                <ui:checkbox.root value="wheelchair">
                    <ui:checkbox.control />
                    <ui:checkbox.label class="font-normal">
                        Wheelchair Access
                    </ui:checkbox.label>
                </ui:checkbox.root>
                <ui:checkbox.root value="sign-language">
                    <ui:checkbox.control />
                    <ui:checkbox.label class="font-normal">
                        Sign Language Interpretation
                    </ui:checkbox.label>
                </ui:checkbox.root>
            </ui:checkboxGroup.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="comment">
            <ui:textarea.root placeholder="Type your message here...">
                <ui:textarea.label>Additional Comments</ui:textarea.label>
                <ui:textarea.textarea />
            </ui:textarea.root>
            <ui:field.error />
        </ui:field.root>

        <ui:field.root name="privacy" required="{true}">
            <ui:checkbox.root name="privacy">
                <ui:checkbox.control />
                <ui:checkbox.label>
                    I consent to the processing of my data in accordance with the privacy policy.
                </ui:checkbox.label>
            </ui:checkbox.root>
            <ui:field.error />
        </ui:field.root>

        <ui:button type="submit" attributes="{ui:ref(name: 'submit-button', asArray: '{true}')}">
            Register
        </ui:button>
    </ui:form.content>

    <ui:form.indicator
        state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Error')}"
        class="space-y-3">
        <h2 class="font-medium">Error</h2>
        <ui:form.errorText class="block">There was an error submitting your registration. Please try again.</ui:form.errorText>
        <ui:button type="reset" class="mt-4">
            Back to form
        </ui:button>
    </ui:form.indicator>

    <ui:form.indicator
        state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Success')}"
        class="space-y-3">
        <h2 class="font-medium">Success</h2>
        <ui:form.successText class="block">Your registration was submitted successfully. Thank you!</ui:form.successText>
    </ui:form.indicator>
</ui:form.root>

<vite:asset entry="EXT:docs/Resources/Private/Components/EventRegistration/EventRegistration.entry.ts" />
```

**EventRegistration.entry.ts**

```ts
import { getHydrationData, mountAll } from 'fluid-primitives';
import { Form, type FormValues } from 'fluid-primitives/form';
import { z } from 'zod';

mountAll('eventRegistration', ({ props, createHydrator }) => {
    const data = getHydrationData('form', props.id + '-form');
    if (!data) return;

    const hydrator = createHydrator();

    const schema = z.object({
        ticketType: z.enum(['vip', 'standard', 'student'], 'Please select a ticket type'),
        ticketCount: z.coerce
            .number('Please enter a valid number of tickets')
            .min(1, 'You must register at least 1 ticket')
            .max(10, 'You can only register up to 10 tickets'),
        person: z.object({
            name: z.string('Please enter your name').min(1, 'Please enter your name'),
            email: z.email('Please enter your email'),
            phone: z.string().optional(),
            country: z.string('Please select your country').min(1, 'Please select your country'),
        }),
        mode: z.enum(['person', 'virtual'], 'Please select a mode of attendance'),
        studentId: z.string().optional(),
        a11yNeeds: z.array(z.string()).optional(),
        comment: z.string().optional(),
        privacy: z.literal('1', 'You must agree to the privacy policy'),
    });

    const needsStudentId = (values: FormValues) => values.get('ticketType') === 'student';

    const form = new Form({
        ...data.props,
        // We need to manually validate the schema because of the conditional logic for studentId.
        // Zod's refine or discriminatedUnion would result in inconsistent validation while the user interacts with the form
        // otherwise we could just pass the schema instead of the callback
        validation: ({ values, validateWithStandardSchema }) => {
            let errors = validateWithStandardSchema(schema);

            const hasStudentId = values.get('studentId') !== null && values.get('studentId') !== '';

            if (needsStudentId(values) && !hasStudentId) {
                errors = {
                    ...errors,
                    studentId: {
                        messages: ['You need to provide a student id for the student ticket'],
                    },
                };
            }

            return errors;
        },
        onSubmit: async ({ api, post }) => {
            // Wait at least 800ms so we dont flash a loading state and show were working very hard
            const [response] = await Promise.all([
                post(api.getAction()),
                new Promise(resolve => setTimeout(resolve, 800)),
            ]);

            const data = await response.json();

            if (!response.ok) {
                api.setErrorText(
                    data.message ||
                        'There was an error submitting your registration. Please try again.'
                );
                return false;
            }

            api.setSuccessText(
                data.message || 'Your registration was submitted successfully. Thank you.'
            );

            return true;
        },
        render: form => {
            // Conditionally hide and show the studentId field based on the ticket type.
            // Note we also disable it, so it's omitted by FormData and therefore not validated or passed to the server.
            const showStudentFields = needsStudentId(form.api.getValues());

            const studentIdField = form.api.getField('studentId')!;
            studentIdField.getRootEl()!.hidden = !showStudentFields;
            studentIdField.setDisabled(!showStudentFields);

            // Update submit button based on form state
            const submitButton = hydrator.getElement('submit-button');
            if (submitButton) {
                if (form.api.isSubmitting) {
                    submitButton.setAttribute('aria-disabled', 'true');
                    submitButton.textContent = 'Submitting...';
                } else {
                    submitButton.setAttribute('aria-disabled', 'false');
                    submitButton.textContent = 'Submit';
                }
            }

            // ...
        },
    });

    form.init();
});

```

**EventRegistrationController.php**

```php
<?php

declare(strict_types=1);

namespace FluidPrimitives\Docs\Controller;

use FluidPrimitives\Docs\Domain\Model\EventRegistration;
use FluidPrimitives\Docs\Domain\Repository\EventRegistrationRepository;
use FluidPrimitives\Docs\Domain\Validator\EventRegistrationValidator;
use Jramke\FluidPrimitives\Traits\AjaxValidationTrait;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Extbase\Attribute\Validate;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;

final class EventRegistrationController extends ActionController
{
    use AjaxValidationTrait;

    public function __construct(
        private readonly EventRegistrationRepository $eventRegistrationRepository,
        private readonly PersistenceManagerInterface $persistenceManager,
    ) {}

    public function registrationAction(
        #[Validate(validator: EventRegistrationValidator::class)]
        EventRegistration $eventRegistration,
    ): ResponseInterface {
        // Reject VIP tickets as a server-side business rule without a validator.
        // Simply moving this into the validator and calling addErrorForProperty would result in the same behavior.
        if ($eventRegistration->getTicketType() === 'vip') {
            $payload = ['eventRegistration.ticketType' => ['VIP tickets are sold out.']];
            $response = $this->jsonResponse(json_encode($payload) ?: null)->withStatus(422);
            throw new PropagateResponseException($response, 422);
        }

        $this->eventRegistrationRepository->add($eventRegistration);
        // Flushed explicitly since we escape the normal response cycle via PropagateResponseException
        // below - Extbase's own end-of-request persistAll would otherwise never run.
        $this->persistenceManager->persistAll();

        // Send confirmation email, etc.

        $response = $this->jsonResponse(
            json_encode([
                'success' => true,
                'message' => 'Your registration was submitted successfully. Thank you.',
            ]) ?: null,
        )->withStatus(200);
        throw new PropagateResponseException($response, 200);
    }

    #[\Override]
    protected function errorAction(): ResponseInterface
    {
        $this->throwJsonValidationErrorResponse();
        return parent::errorAction();
    }
}

```

**EventRegistration.php**

```php
<?php

declare(strict_types=1);

namespace FluidPrimitives\Docs\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\FileUpload;
use TYPO3\CMS\Extbase\Attribute\Validate;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class EventRegistration extends AbstractEntity
{
    #[Validate(validator: 'String')]
    #[Validate(validator: 'NotEmpty')]
    #[Validate(validator: 'RegularExpression', options: ['regularExpression' => '/^(vip|standard|student)$/'])]
    protected string $ticketType = '';

    #[Validate(validator: 'NumberRange', options: ['minimum' => 1, 'maximum' => 10])]
    protected int $ticketCount = 1;

    protected EventRegistrationPerson $person;

    #[Validate(validator: 'String')]
    #[Validate(validator: 'NotEmpty')]
    #[Validate(validator: 'RegularExpression', options: ['regularExpression' => '/^(person|virtual)$/'])]
    protected string $mode = '';

    #[Validate(validator: 'String')]
    protected string $studentId = '';

    #[FileUpload(validation: [
        'fileSize' => ['maximum' => '5M'],
        'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
        'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
    ], uploadFolder: '1:/user_upload/event_registrations/badge_photos/')]
    protected ?FileReference $badgePhoto = null;

    /**
     * KNOWN LIMITATION: this never round-trips through a read. Persistence writes it correctly as a
     * comma-separated string (Extbase's `getPlainValue()` implodes arrays for storage), but reading
     * it back (e.g. `$repository->findByUid()`, so an edit form can pre-check these boxes) always
     * yields an empty array, for two compounding reasons:
     * - Extbase's `DataMapper::thawProperties()` has no support at all for hydrating a plain
     *   `array`-typed property from a column (`'array' => null, // Not supported, yet!`).
     * - Renaming the backing property so it no longer collides with these `array`-typed accessors
     *   (tried: a `string $a11yNeedsRaw` + a `Configuration/Extbase/Persistence/Classes.php` column
     *   mapping) does make the DB value hydrate correctly, but then breaks *writing* instead: Extbase's
     *   `PersistentObjectConverter::getTypeOfChildProperty()` requires a property literally named
     *   `a11yNeeds` to exist on the class before it will map the submitted `a11yNeeds[]` checkbox
     *   values onto it at all, independent of that property's declared type.
     * Properly fixing this needs a custom `PropertyMappingConfiguration` (to point the converter at a
     * differently-named target property) rather than a plain property rename - out of scope for now.
     */
    /** @var array<string> */
    #[Validate(validator: 'Collection', options: ['elementValidator' => 'String'])]
    protected array $a11yNeeds = [];

    #[Validate(validator: 'Text')]
    #[Validate(validator: 'StringLength', options: ['maximum' => 500])]
    protected string $comment = '';

    #[Validate(validator: 'Boolean', options: ['is' => true])]
    protected bool $privacy = false;

    public function getTicketType(): string
    {
        return $this->ticketType;
    }

    public function setTicketType(string $ticketType): void
    {
        $this->ticketType = $ticketType;
    }

    public function getTicketCount(): int
    {
        return $this->ticketCount;
    }

    public function setTicketCount(int $ticketCount): void
    {
        $this->ticketCount = $ticketCount;
    }

    public function getPerson(): EventRegistrationPerson
    {
        return $this->person;
    }

    public function setPerson(EventRegistrationPerson $person): void
    {
        $this->person = $person;
    }

    public function getMode(): string
    {
        return $this->mode;
    }

    public function setMode(string $mode): void
    {
        $this->mode = $mode;
    }

    public function getStudentId(): string
    {
        return $this->studentId;
    }

    public function setStudentId(string $studentId): void
    {
        $this->studentId = $studentId;
    }

    public function getBadgePhoto(): ?FileReference
    {
        return $this->badgePhoto;
    }

    public function setBadgePhoto(?FileReference $badgePhoto): void
    {
        $this->badgePhoto = $badgePhoto;
    }

    /** @return array<string> */
    public function getA11yNeeds(): array
    {
        return $this->a11yNeeds;
    }

    /** @param array<string> $a11yNeeds */
    public function setA11yNeeds(array $a11yNeeds): void
    {
        $this->a11yNeeds = $a11yNeeds;
    }

    public function getComment(): string
    {
        return $this->comment;
    }

    public function setComment(string $comment): void
    {
        $this->comment = $comment;
    }

    public function getPrivacy(): bool
    {
        return $this->privacy;
    }

    public function setPrivacy(bool $privacy): void
    {
        $this->privacy = $privacy;
    }
}

```

**EventRegistrationPerson.php**

```php
<?php

declare(strict_types=1);

namespace FluidPrimitives\Docs\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\Validate;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class EventRegistrationPerson extends AbstractEntity
{
    #[Validate(validator: 'String')]
    #[Validate(validator: 'NotEmpty')]
    #[Validate(validator: 'StringLength', options: ['maximum' => 255])]
    protected string $name = '';

    #[Validate(validator: 'EmailAddress')]
    #[Validate(validator: 'NotEmpty')]
    #[Validate(validator: 'StringLength', options: ['maximum' => 255])]
    protected string $email = '';

    #[Validate(validator: 'String')]
    #[Validate(validator: 'StringLength', options: ['maximum' => 255])]
    protected string $phone = '';

    #[Validate(validator: 'String')]
    #[Validate(validator: 'NotEmpty')]
    protected string $country = '';

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $this->name = $name;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): void
    {
        $this->email = $email;
    }

    public function getPhone(): string
    {
        return $this->phone;
    }

    public function setPhone(string $phone): void
    {
        $this->phone = $phone;
    }

    public function getCountry(): string
    {
        return $this->country;
    }

    public function setCountry(string $country): void
    {
        $this->country = $country;
    }
}

```

**EventRegistrationValidator.php**

```php
<?php

declare(strict_types=1);

namespace FluidPrimitives\Docs\Domain\Validator;

use FluidPrimitives\Docs\Domain\Model\EventRegistration;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;

final class EventRegistrationValidator extends AbstractValidator
{
    protected function isValid(mixed $value): void
    {
        if (!$value instanceof EventRegistration) {
            // addError will result in a full form error in the frontend
            $this->addError(
                'The ' .
                self::class .
                ' can only handle classes of type ' .
                EventRegistration::class .
                '. ' .
                // The validator framework contract guarantees an object here, just not necessarily
                // one of our expected type - that's exactly the mismatch being reported.
                // @mago-expect analysis:mixed-operand
                $value::class .
                ' given instead.',
                1782582413,
            );

            return;
        }

        if ($this->needsStudentId($value) && $value->getStudentId() === '') {
            // addErrorForProperty will result in a field error in the frontend
            $this->addErrorForProperty(
                'studentId',
                'You need to provide a student id for the student ticket',
                1782582859,
            );
        }
    }

    private function needsStudentId(EventRegistration $eventRegistration): bool
    {
        return $eventRegistration->getTicketType() === 'student';
    }
}

```
