Skip to content

Forms Guide

The FormPanelBlock renders schema-driven forms from the page contract. The backend defines the form structure, field types, and validation — the frontend renders it automatically.

Form Schema Structure

A form is described by FormPanelBlockData, which contains the submission endpoint, HTTP method, schema tree, current values, and errors:

FormPanelBlockData
├── action: "/api/endpoint"
├── method: "post" | "put" | "patch"
├── schema: FormSchemaNode[]
│   ├── FormSectionNode  { kind: 'section', id, label, children[] }
│   ├── FormGroupNode    { kind: 'group', id, columns, children[] }
│   ├── FormHeaderNode   { kind: 'header', label }
│   └── FormFieldNode    { kind: 'field', key, component, props }
├── values: { field_key: current_value }
├── errors: { field_key: "error message" }
└── meta: { submitLabel, cancelLabel, cancelHref, multiStep }

Available Field Components

The component field on each FormFieldNode determines which input component renders:

ComponentValue TypeDescription
textstringSingle-line text input.
textareastringMulti-line text area. Supports rows prop.
emailstringEmail input with browser validation.
passwordstringPassword input with masked characters.
urlstringURL input with browser validation.
intnumberInteger input. Supports min, max, step.
floatnumberDecimal number input. Supports min, max, step.
currencynumberLocale-aware currency input. Props: currency, currencyLocale.
selectstringDropdown select. Requires options array.
multiselectstring[]Searchable multi-select with chips (>8 options) or checkboxes (<=8).
radiostringRadio button group. Requires options array.
checkboxbooleanSingle checkbox toggle.
switchbooleanToggle switch control.
datestringCalendar popover date picker (YYYY-MM-DD).
datetimestringCalendar + time picker (YYYY-MM-DDTHH:mm).
phonestringInternational phone input (E.164). Country selector with flags.
documentstringCountry-aware document input (30+ locales via validator.js).
colorstringColor picker + hex input.
slugstringURL-friendly slug with auto-generation.
tagsstring[]Chip-based tag input.
durationnumberDuration input in seconds.
fileFileFile upload. Supports accept and multiple props.
entity_pickerstringSearchable entity selection with avatar support.
ratingnumberStar rating input. Props: maxRating (default 5).
date_rangestring[]Date range selector with filter operators and presets.
hiddenstringHidden field, not rendered visually.
staticstringRead-only display value (not editable).
headern/aSection header label inside a group.

Common Field Props

PropTypeDefaultDescription
labelstring(required)Field label displayed above the input.
placeholderstringPlaceholder text inside the input.
helpTextstringHelp text displayed below the input.
requiredbooleanfalseWhether the field is required.
disabledbooleanfalseWhether the field is disabled.
options{ value, label }[]Options for select, multiselect, and radio fields.
minnumberMinimum value for number fields.
maxnumberMaximum value for number fields.
visible_whenFormConditionShow field only when condition is met.
hidden_whenFormConditionHide field when condition is met.
required_whenFormConditionMake field required when condition is met.
disabled_whenFormConditionDisable field when condition is met.

Conditional Fields

Fields can be shown, hidden, required, or disabled based on other field values using FormCondition objects:

ts
{
    kind: 'field',
    key: 'notification_email',
    component: 'email',
    props: {
        label: 'Notification Email',
        visible_when: {
            field: 'email_notifications',
            operator: 'equals',
            value: 'true',
        },
    },
}

Available operators:

OperatorDescription
equalsField value strictly equals the given value.
not_equalsField value does not equal the given value.
inField value is included in the given array of values.
not_inField value is not included in the given array of values.

Sections and Groups

Form schemas support structural nodes for organizing fields:

  • Sections — collapsible containers with a label and optional defaultCollapsed.
  • Groups — multi-column layouts (columns: 1 | 2).
  • Nesting — Section > Group > Field.
ts
{
    kind: 'section',
    id: 'contact',
    label: 'Contact Information',
    defaultCollapsed: false,
    children: [
        {
            kind: 'group',
            id: 'name-group',
            columns: 2,
            children: [
                { kind: 'field', key: 'first_name', component: 'text', props: { label: 'First Name', required: true } },
                { kind: 'field', key: 'last_name', component: 'text', props: { label: 'Last Name', required: true } },
            ],
        },
        { kind: 'field', key: 'email', component: 'email', props: { label: 'Email', required: true } },
    ],
}

FormFieldNode Discriminated Union

FormFieldNode is a TypeScript discriminated union — narrow by component to access type-specific props:

SimpleFormField      — text, email, url, password, color
TextareaFormField    — textarea (+ rows)
NumericFormField     — int, float (+ min, max, step)
SelectFormField      — select, radio (+ options)
MultiSelectFormField — multiselect (+ options)
BooleanFormField     — checkbox, switch
DateFormField        — date, datetime
DurationFormField    — duration
FileFormField        — file (+ accept, multiple, maxFiles, maxSize)
EntityPickerFormField — entity_picker (+ autocomplete props)
PhoneFormField       — phone (+ country props)
DocumentFormField    — document (+ document validation props)
CurrencyFormField    — currency (+ currency, currencyLocale)
SlugFormField        — slug (+ sourceField, prefix)
TagsFormField        — tags (+ maxTags)
RatingFormField      — rating (+ maxRating)
DateRangeFormField   — date_range (+ operator)
HeaderFormField      — header (label only, no FieldPropsBase)
HiddenFormField      — hidden (key + default value)
StaticFormField      — static (read-only display)

Narrow by component to access type-specific props:

ts
if (field.component === 'entity_picker') {
    // TypeScript narrows to EntityPickerFormField
    field.props.autocompleteHref;  // ✓ available
    field.props.entityAvatarField; // ✓ available
}

Server Errors

Validation errors returned by the backend are passed in the errors field of FormPanelBlockData. Each key maps to a field key:

php
// Backend returns errors:
return back()->withErrors(['email' => 'Invalid email format']);
ts
// Contract (received by frontend)
{
    errors: {
        email: 'Invalid email format'
    }
}

Real-time feedback

Errors are displayed inline next to each field automatically. The form block matches error keys to field keys in the schema.

PHP Example

Full PHP example showing how to build a form contract from the backend:

php
$form = [
    'type' => 'form_panel',
    'key' => 'settings-form',
    'data' => [
        'action' => '/api/settings',
        'method' => 'put',
        'schema' => [
            [
                'kind' => 'section',
                'id' => 'general',
                'label' => 'General Settings',
                'children' => [
                    [
                        'kind' => 'field',
                        'key' => 'site_name',
                        'component' => 'text',
                        'props' => [
                            'label' => 'Site Name',
                            'required' => true,
                        ],
                    ],
                    [
                        'kind' => 'field',
                        'key' => 'language',
                        'component' => 'select',
                        'props' => [
                            'label' => 'Language',
                            'options' => [
                                ['value' => 'en', 'label' => 'English'],
                                ['value' => 'pt_br', 'label' => 'Portuguese'],
                            ],
                        ],
                    ],
                ],
            ],
        ],
        'values' => $currentValues,
        'errors' => $request->errors() ?? [],
        'meta' => [
            'submitLabel' => 'Save Settings',
            'cancelHref' => '/dashboard',
            'validation' => 'both',  // 'client' | 'server' | 'both'
        ],
    ],
];

Client-Side Validation

The FormPanelBlock uses react-hook-form with Zod for client-side validation. A Zod schema is generated dynamically from the FormSchemaNode[] tree:

  • Field types produce appropriate validators (email.email(), int.int(), url.url())
  • required fields get .min(1) for strings or non-nullable for numbers
  • The validation prop adds custom rules (see below)
  • Conditional fields are excluded from validation when hidden

Validation Props

Add a validation object to any field's props:

php
[
    'kind' => 'field',
    'key' => 'username',
    'component' => 'text',
    'props' => [
        'label' => 'Username',
        'required' => true,
        'validation' => [
            'minLength' => 3,
            'maxLength' => 32,
            'pattern' => '^[a-z0-9_]+$',
            'patternMessage' => 'Only lowercase letters, numbers, and underscores',
        ],
    ],
]

Validation Modes

Control validation behavior via meta.validation:

ModeClientServerUse Case
bothZodInertiaDefault — immediate feedback + authoritative server check
clientZodPreview or test forms
serverInertiaForms with complex server-only rules

Specialized Field Props

Phone Field

International phone input with country flag selector. Value is E.164 format.

PropTypeDefaultDescription
defaultCountrystring"BR"ISO 3166-1 alpha-2 country code
fixedCountrybooleanfalseWhen true, locks to defaultCountry (no selector)
php
['component' => 'phone', 'props' => ['label' => 'Phone', 'defaultCountry' => 'US', 'fixedCountry' => true]]

Document Field

Country-aware document validation using validator.js isTaxID (30+ countries). The host sends a locale and an optional scope to restrict to person or company documents.

PropTypeDefaultDescription
documentTypestring"generic"validator.js locale ("pt-BR", "en-US", "de-DE", etc.), "generic", or custom key
documentScopestring"any""person" (individual), "company" (business), or "any" (both)
documentMasksobjectCustom format masks merged with built-in. # = digit placeholder.

Supported locales: pt-BR, en-US, en-GB, en-CA, en-IE, de-DE, de-AT, fr-FR, fr-BE, fr-LU, it-IT, es-ES, es-AR, pt-PT, nl-NL, pl-PL, sv-SE, dk-DK, fi-FI, hr-HR, hu-HU, ro-RO, bg-BG, cs-CZ, sk-SK, sl-SI, el-GR, el-CY, et-EE, lv-LV, lt-LT, mt-MT, uk-UA.

php
// Brazil — CPF only (person)
['component' => 'document', 'props' => [
    'label' => 'CPF',
    'documentType' => 'pt-BR',
    'documentScope' => 'person',
]]

// Brazil — CNPJ only (company)
['component' => 'document', 'props' => [
    'label' => 'CNPJ',
    'documentType' => 'pt-BR',
    'documentScope' => 'company',
]]

// Brazil — both CPF and CNPJ (default scope: "any")
['component' => 'document', 'props' => [
    'label' => 'CPF / CNPJ',
    'documentType' => 'pt-BR',
]]

// US — EIN only (company)
['component' => 'document', 'props' => [
    'label' => 'EIN',
    'documentType' => 'en-US',
    'documentScope' => 'company',
]]

// Custom country with host-defined mask
[
    'component' => 'document',
    'props' => [
        'label' => 'RUT',
        'documentType' => 'cl-CL',
        'documentMasks' => [
            'cl-CL' => ['pattern' => '##.###.###-#', 'maxLength' => 12],
        ],
    ],
]

Each scope gets its own mask and placeholder. Placeholders resolve via i18n key middag.ui.form.document.{locale}.{scope} — e.g., pt-BR.person = "CPF", pt-BR.company = "CNPJ", pt-BR.any = "CPF / CNPJ". Built-in scoped masks: pt-BR, en-US, it-IT, fr-FR, es-AR, pl-PL, nl-NL, fi-FI, ro-RO, sv-SE.

Entity Picker

Searchable entity selection with avatar and subtitle support:

PropTypeDefaultDescription
autocompleteHrefstringAsync search URL. Receives ?q=search_term.
autocompleteMinCharsnumber2Min chars before search triggers
entityAvatarFieldstringField name in response for avatar URL
entitySubtitleFieldstringField name in response for subtitle

Static mode (options prop): local filtering, no HTTP requests. Async mode (autocompleteHref): debounced fetch, avatar + subtitle display.

php
[
    'component' => 'entity_picker',
    'props' => [
        'label' => 'Instructor',
        'autocompleteHref' => '/api/users/search',
        'autocompleteMinChars' => 2,
        'entityAvatarField' => 'avatar_url',
        'entitySubtitleField' => 'department',
    ],
]

API response format: { items: [{ value, label, avatar_url, department }] }

Currency Field

Locale-aware currency input with formatted display:

PropTypeDefaultDescription
currencystring"BRL"ISO 4217 currency code
currencyLocalestringOverride locale for formatting (e.g. "en-US")

Multiselect Field

Automatically switches between two modes:

  • ≤8 options: Checkbox list (simple, always visible)
  • >8 options: Searchable combobox with selected items as removable badges

MIDDAG © 2015-2026