Skip to content
Frontend

Custom Fields ​

You can add your own field types. A custom field has two parts:

  1. a PHP class that extends Erag\InertiaForms\Fields\Field,
  2. a frontend component registered on <Form> through the components prop.

This page builds a star rating field called Rating.

Live demo ​

The Custom fields form below uses three custom fields: Rating (stars with arrow-key support), CodeInput (one box per digit, with paste) and QuantityStepper (minus and plus buttons with a unit). Their PHP classes and Vue components are on the Live Demo page.

Form class

Accent
CustomFieldsForm.php

Examples ​

Each example below is a live form. The field class is written next to the form so the snippet is complete; in your app it lives in its own file, such as app/Forms/Fields/Rating.php. The matching frontend components are already registered in these docs.

The Rating field ​

The class built on this page, used in a small review form. The value starts as null and becomes the number of stars you pick.

php
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Form;

class Rating extends Field
{
    protected int $stars = 5;

    public function component(): string
    {
        return 'Rating';
    }

    public function stars(int $stars): static
    {
        $this->stars = $stars;

        return $this;
    }

    public function emptyValue(): mixed
    {
        return null;
    }

    protected function typeRules(): array
    {
        return ['integer', 'between:1,'.$this->stars];
    }

    protected function props(): array
    {
        return ['stars' => $this->stars];
    }
}

class ReviewForm extends Form
{
    public function fields(): array
    {
        return [
            Rating::make('score')->label('How was your stay?')->stars(5)->required(),
            Textarea::make('comment')->rows(3),
            Submit::make('Send review'),
        ];
    }
}

ReviewForm::make();

Advanced: a stepper with common methods ​

A second custom field with its own options (between(), unit()). The common methods work on it unchanged: required(), help(), and visibleWhen() with clearWhenHidden() on the children count.

php
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;

class QuantityStepper extends Field
{
    protected int $min = 0;

    protected int $max = 99;

    protected ?string $unit = null;

    public function component(): string
    {
        return 'QuantityStepper';
    }

    public function between(int $min, int $max): static
    {
        [$this->min, $this->max] = [$min, $max];

        return $this;
    }

    public function unit(?string $unit): static
    {
        $this->unit = $unit;

        return $this;
    }

    public function emptyValue(): mixed
    {
        return $this->min;
    }

    protected function typeRules(): array
    {
        return ['integer', 'between:'.$this->min.','.$this->max];
    }

    protected function props(): array
    {
        return ['min' => $this->min, 'max' => $this->max, 'step' => 1, 'unit' => $this->unit];
    }
}

class TicketForm extends Form
{
    public function fields(): array
    {
        return [
            TextInput::make('name')->required(),
            QuantityStepper::make('tickets')->between(1, 10)->unit('tickets')->required(),
            Toggle::make('bring_children')->label('Bringing children?'),
            QuantityStepper::make('children')
                ->between(1, 5)
                ->unit('children')
                ->help('Under 12 go free.')
                ->visibleWhen('bring_children', true)
                ->clearWhenHidden(),
            Submit::make('Reserve'),
        ];
    }
}

TicketForm::make();

1. The PHP class ​

php
<?php

namespace App\Forms\Fields;

use Erag\InertiaForms\Fields\Field;

class Rating extends Field
{
    protected int $stars = 5;

    /**
     * The frontend component name. Must match the key in `components`.
     */
    public function component(): string
    {
        return 'Rating';
    }

    public function stars(int $stars): static
    {
        $this->stars = $stars;

        return $this;
    }

    /**
     * Value before the user picks anything.
     */
    public function emptyValue(): mixed
    {
        return null;
    }

    /**
     * Rules added after `required`/`nullable` and before custom rules.
     */
    protected function typeRules(): array
    {
        return ['integer', 'between:1,'.$this->stars];
    }

    /**
     * Extra keys sent to the frontend next to the common ones.
     */
    protected function props(): array
    {
        return ['stars' => $this->stars];
    }
}

Use it like any other field:

php
use App\Forms\Fields\Rating;

Rating::make('score')->label('How was your stay?')->stars(5)->required();

Methods you can override ​

MethodPurpose
component(): stringRequired. The component name looked up in the frontend registry.
props(): arrayExtra keys merged into the serialized field.
typeRules(): arrayRules generated from the field's configuration.
validationRules(): arrayFull control over the rules, keyed by attribute (use this for name.* rules).
emptyValue(): mixedThe value used when there is no default or bound value. Default ''.
formatValue(mixed $value): mixedConvert a bound model value for the browser.
hasValue(): boolReturn false for display-only fields that carry no data.
validationAttributes(): arrayAttribute names for validation messages, e.g. for name.*.key. :position is replaced with the row number.
validationMessages(): arrayCustom messages keyed by attribute.rule. The form's own messages() still wins.
dehydrateValue(mixed $value): mixedChange what $form->validated() returns for this field, e.g. turn rows into an array.
validationRulesFor(array $data) (and validationAttributesFor / validationMessagesFor)Rules that depend on the submitted values, like one set per Blocks item.
hasFiles(): boolReturn true when the field sends files, so the form is submitted as multipart.
getLabel(): stringChange how the label is built.

All common methods (label(), required(), visibleWhen(), authorize(), ...) work on custom fields automatically.

2. The frontend component ​

A field component receives these props:

PropVueReactSvelteDescription
field✓✓✓The serialized field, including your props() keys.
id✓✓✓DOM id. The wrapper's <label for> points to it.
valuemodelValuevaluevalue ($bindable)The current value.
error✓✓✓The first error message, if any.
disabled✓✓✓true when the field is disabled.
describedBy✓✓✓Ids of the help and error elements, for aria-describedby.
updateemit update:modelValueonChange(value)onChange(value)Report a new value.
vue
<!-- resources/js/components/Rating.vue -->
<script setup lang="ts">
import type { FieldComponentProps } from '@erag/inertia-forms-vue';

const props = defineProps<FieldComponentProps>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();

const stars = Number(props.field.stars ?? 5);
</script>

<template>
    <div
        :id="id"
        role="radiogroup"
        :aria-describedby="describedBy"
        :aria-invalid="error ? true : undefined"
        class="flex gap-1"
    >
        <button
            v-for="star in stars"
            :key="star"
            type="button"
            role="radio"
            :aria-checked="modelValue === star"
            :aria-label="`${star} of ${stars}`"
            :disabled="disabled"
            class="text-2xl"
            :class="Number(modelValue) >= star ? 'text-amber-400' : 'text-zinc-300 dark:text-zinc-600'"
            @click="emit('update:modelValue', star)"
        >
            ★
        </button>
    </div>
</template>
tsx
// resources/js/components/Rating.tsx
import type { FieldComponentProps } from '@erag/inertia-forms-react';

export function Rating({ field, id, value, error, disabled, describedBy, onChange }: FieldComponentProps) {
    const stars = Number(field.stars ?? 5);

    return (
        <div
            id={id}
            role="radiogroup"
            aria-describedby={describedBy}
            aria-invalid={error ? true : undefined}
            className="flex gap-1"
        >
            {Array.from({ length: stars }, (_, index) => index + 1).map((star) => (
                <button
                    key={star}
                    type="button"
                    role="radio"
                    aria-checked={value === star}
                    aria-label={`${star} of ${stars}`}
                    disabled={disabled}
                    className={`text-2xl ${Number(value) >= star ? 'text-amber-400' : 'text-zinc-300 dark:text-zinc-600'}`}
                    onClick={() => onChange(star)}
                >
                    ★
                </button>
            ))}
        </div>
    );
}
svelte
<!-- resources/js/components/Rating.svelte -->
<script lang="ts">
    import type { FieldComponentProps } from '@erag/inertia-forms-svelte';

    let { field, id, value = $bindable(), error, disabled, describedBy, onChange }: FieldComponentProps =
        $props();

    const stars = Number(field.stars ?? 5);

    function pick(star: number) {
        value = star;
        onChange?.(star);
    }
</script>

<div
    {id}
    role="radiogroup"
    aria-describedby={describedBy}
    aria-invalid={error ? true : undefined}
    class="flex gap-1"
>
    {#each Array.from({ length: stars }, (_, index) => index + 1) as star (star)}
        <button
            type="button"
            role="radio"
            aria-checked={value === star}
            aria-label={`${star} of ${stars}`}
            {disabled}
            class="text-2xl {Number(value) >= star ? 'text-amber-400' : 'text-zinc-300 dark:text-zinc-600'}"
            onclick={() => pick(star)}
        >
            ★
        </button>
    {/each}
</div>

Set aria-invalid

<Form> finds the first invalid field by looking for aria-invalid="true". Set it when error is present so "scroll to first error" works with your component too.

3. Register the component ​

Pass it to <Form> under the same name that component() returns:

vue
<script setup lang="ts">
import { Form, type FormSchema } from '@erag/inertia-forms-vue';
import Rating from '@/components/Rating.vue';

defineProps<{ form: FormSchema }>();
</script>

<template>
    <Form :form="form" :components="{ Rating }" />
</template>
tsx
import { Form, type FormSchema } from '@erag/inertia-forms-react';
import { Rating } from '@/components/Rating';

const components = { Rating };

export default function Review({ form }: { form: FormSchema }) {
    return <Form form={form} components={components} />;
}
svelte
<script lang="ts">
    import { Form, type FormSchema } from '@erag/inertia-forms-svelte';
    import Rating from '@/components/Rating.svelte';

    let { form }: { form: FormSchema } = $props();
</script>

<Form {form} components={{ Rating }} />

In React, define the components object outside the component (or memoize it) so it keeps the same identity between renders.

If a field names a component that isn't registered, it is skipped and a warning is logged in the console.

How custom fields are wrapped ​

<Form> wraps every field component (built-in or custom) in a wrapper that renders:

  • a <label for={id}> with the field label and a * when required,
  • your component,
  • the help text,
  • the error message.

So your component only needs to draw the control itself.

Replacing a built-in component ​

Register a component under a built-in name to replace it everywhere in that form. The built-in names are the keys of the exported builtInComponents object:

TextInput, Textarea, Combobox, Select, Radio, Checkbox, CheckboxGroup, Toggle, DatePicker, TimePicker, ColorPicker, Slider, FileUpload, TagsInput, KeyValue, Blocks, Repeater, Link, Slug, OtpInput, Composer, Heading, Text, Html, Separator, Callout.

vue
<template>
    <Form :form="form" :components="{ DatePicker: MyDatePicker }" />
</template>
tsx
<Form form={form} components={{ DatePicker: MyDatePicker }} />
svelte
<Form {form} components={{ DatePicker: MyDatePicker }} />

Your replacement receives the same props as the original. Notes:

  • PHP serializes both Combobox and its Select alias with the component name Combobox. Register your dropdown under either name: a Select replacement is used for Combobox fields too, unless you also register Combobox. The same component handles searchable() and searchUsing() fields.
  • A Blocks replacement also renders Repeater fields, unless you register a separate Repeater component.
  • Heading, Text, Html, Separator and Callout are rendered without the label, help and error wrapper, and receive no useful value.
  • Hidden and Submit are handled by <Form> itself and can't be replaced through components. Use the form's children and a custom button if you need a different submit area.