Skip to content

Kinetix Forms Complete Reference ​

Kinetix Forms is a schema-driven, DTO-powered form building system built specifically for Vue 3, Inertia.js 3, and Tailwind CSS. By defining form structures, layouts, validation, and lifecycles in PHP, you serialize configurations into structured JSON DTOs that are natively consumed by premium Vue 3 elements.


1. Core Architecture & Concept ​

Kinetix Forms decouples form configuration from layout rendering. Instead of writing verbose HTML templates with complex binding logic, you define the entire layout, validation rules, default states, and visibility settings in PHP.

Key Principles ​

  1. Declarative Layouts: Responsive columns, cards, and layouts are defined through a fluent API.
  2. TypeScript Generation: Serialized data structure maps to typescript types generated via Spatie's Laravel TypeScript Transformer.
  3. Tailwind JIT Compliance: Layout columns are computed using CSS custom properties or inline styles rather than dynamic Tailwind classes, avoiding purge and compilation errors.
  4. Unified Validation: Frontend validation errors map to standard Laravel Validator keybags, maintaining unified error rendering.

Defining a Form as a Class ​

As an alternative to the inline ->schema([...]) builder, subclass Form and override the buildSchema() hook. The hook runs in the constructor, so the schema is ready as soon as the form is instantiated.

php
use Happones\Kinetix\Forms\Form;
use Happones\Kinetix\Forms\Components\TextInput;

class ProfileForm extends Form
{
    protected function buildSchema(): array
    {
        return [
            TextInput::make('name')->required()->maxLength(100),
            TextInput::make('email')->email()->required(),
        ];
    }
}

Fields from a FormRequest ​

A FormRequest's rules already say a lot about its fields. Form::fromRequest() builds a form from them:

php
Form::fromRequest(StorePostRequest::class);          // create
Form::fromRequest(UpdatePostRequest::class, $post);  // filled from the record
RulesField
booleanToggle
acceptedCheckbox
in:a,b, Rule::in(), Rule::enum()Select with those options
array + name.* with in:CheckboxList
array:title,colorKeyValue with those keys
arrayTagsInput
jsonJsonInput
timezoneTimezoneSelect
date, date_format, before/afterDatePicker; DateTimePicker when the format has a time or the name ends in _at
integer, numeric, decimalNumberField, with min/max
file, image, mimes, File::image()FileUpload (image(), multiple() for an array)
email, url, password/Password::…TextInput of that kind
max: over 255Textarea
anything elseTextInput

required (unless sometimes), max/min and the request's attributes() labels carry over, and confirmed adds the _confirmation field. Nested rules (items.*.qty) and fields that are prohibited or exclude are left out.

The rules say what to validate, not how to ask for it, so this is a start. A relation id (exists:users,id) is a plain input, not a select. The rules are read with the current request rather than the route they belong to, so rules that need the route's model ($this->post->id) can't be read; Kinetix throws and asks for a Form class.

To shape the form yourself, write the guess into a class and edit it:

bash
php artisan kinetix:make-form PostForm --from-request="App\Http\Requests\StorePostRequest"

A relation id gets a comment suggesting a Select with ->relationship().

One-Call Rendering ​

A schema-driven form: section, grid, inputs, select, toggleA schema-driven form: section, grid, inputs, select, toggle

Form::render(?Model $record = null) is a static helper that instantiates the form, fills it (from the passed model, or the given fill data), and returns the serialized array in one call:

php
return inertia('Settings/Profile', [
    'profileForm' => ProfileForm::render($user),
]);

2. Forms Lifecycle & State Management ​

Form fields follow a strict lifecycle of data transformation during hydration (filling the form) and dehydration (extracting submitted state).

1. Hydration ​

Hydration extracts properties from a model or an array and binds them to the corresponding form fields. This occurs when you call $form->fill($data).

Hydration Callbacks ​

  • afterStateHydrated(Closure $callback): Executed immediately after a field is populated. The closure receives the raw state value, the field component instance, and the source record (if a Model was passed):
    php
    TextInput::make('username')
        ->afterStateHydrated(function (mixed $state, Field $component, ?Model $record) {
            $component->default(strtolower((string) $state));
        });
  • afterStateUpdated(Closure $callback): Executed when the field's value changes on the client. Combine with live(bool $onBlur = false, ?int $debounce = null) to make the field reactive (push updates on change / blur / debounced):
    php
    TextInput::make('first_name')
        ->live(debounce: 500)
        ->afterStateUpdated(function (mixed $state, Field $component) {
            // react to the new value
        });

Copyable & revealable inputs ​

  • copyable(bool $condition = true): adds a click-to-copy button that copies the field's value to the clipboard.
  • revealable(bool $condition = true): masks the value (password-style) with a toggle to reveal it — ideal for API keys, tokens and secrets. Combine the two for a copyable secret field:
    php
    TextInput::make('api_key')
        ->revealable()
        ->copyable();

2. Dehydration ​

Dehydration processes user inputs, validates them, and transforms the states into database-compatible formats when you call $form->getState($requestData).

Dehydration Callbacks ​

  • dehydrateStateUsing(Closure $callback): Transforms the submitted value before it is returned in the final state array:
    php
    TextInput::make('slug')
        ->dehydrateStateUsing(fn (string $state): string => str($state)->slug()->toString());
  • saved(bool $condition): Instructs the form whether to include the field's value in the output of $form->getState(). For example, presentational helper inputs (like passwords confirmations) should be validated but omitted:
    php
    TextInput::make('password_confirmation')
        ->password()
        ->saved(false); // Validated on request, but excluded from database update payloads

3. Form Layouts & Containers ​

Layout elements organize fields inside grids and visual groupings. They inherit column spans and conditional visibility parameters.

1. Grid ​

The Grid component creates a multi-column layout. It defaults to 2 columns, and a field spans 1 — so two fields sit side by side with no annotation:

Grid layoutGrid layout
php
use Happones\Kinetix\Forms\Components\Grid;
use Happones\Kinetix\Forms\Components\TextInput;

Grid::make()
    ->schema([
        TextInput::make('first_name'),   // 50%
        TextInput::make('last_name'),    // 50%
    ])

Pass a column count for anything else. A 12-column grid gives the finest control:

php
Grid::make(12)
    ->schema([
        TextInput::make('street')->columnSpan(8),
        TextInput::make('number')->columnSpan(4),
    ])

Defaults changed in v0.128.0

The form root is now a 1-column grid and a field's default columnSpan is 1 (it was a 12-column root with columnSpan: 'full'). Plain fields still render full width, and explicit spans inside an explicit Grid::make(12) are unchanged — but a field with no span inside a Grid::make(12) used to fill the row and now takes 1/12. Add ->columnSpanFull() to those, or drop the 12 and let the new default do the work.

Responsive columns & spans ​

Grids are responsive by default: an int means "this many columns from lg up, one below", so Grid::make(2) collapses to a single column on narrow layouts with no annotation. Breakpoints are measured against the form's own width (CSS container queries), not the viewport — a two-column form inside a narrow modal or side pane collapses too.

Both columns() and columnSpan() also take a breakpoint map (default · sm 640 · md 768 · lg 1024 · xl 1280 · 2xl 1536); values carry forward through unspecified breakpoints, and spans are clamped to the columns available at each size so they can never overflow the grid:

php
Section::make('Shipping')
    ->columns(['default' => 1, 'sm' => 2, 'xl' => 3])
    ->schema([
        TextInput::make('street')->columnSpan(['sm' => 2]),
        TextInput::make('number'),
        Textarea::make('notes')->columnSpanFull(),
    ]);
Responsive form columnsResponsive form columns

columns() accepts maps on Grid, Section, Fieldset, Tab and wizard Step alike.

Tailwind Grid-Purge Prevention ​

To ensure column layouts render correctly without depending on dynamic Tailwind compiler classes (which JIT compilers purge), Kinetix evaluates columns / columnSpan parameters into inline CSS variables consumed by static @container rules:

  • columnSpan(int): spans X columns at every size (clamped per breakpoint).
  • columnSpan('full') / columnSpanFull(): spans the whole row.
  • columnSpan([...]) / columns([...]): per-breakpoint values.

2. Section Cards ​

The Section component wraps nested elements in a clean visual container complete with title, description, and column layouts.

php
use Happones\Kinetix\Forms\Components\Section;

Section::make('General Profile')
    ->description('Enter your public accounts info.')
    ->columns(2) // Sections are single-column until told otherwise
    ->schema([
        TextInput::make('display_name'),
        TextInput::make('twitter_handle'),
    ])

Sections inside modals: pass flat

The Section card is designed for pages, where the schema provides its own surface. When a form renders inside a KinetixModal/KinetixSheet, pass the flat prop to <KinetixForm> — the panel is already the surface, so Sections drop their card chrome (border/shadow/background) and render as divided groups with their heading and description intact, instead of nesting a card inside the modal. Kinetix's own modal hosts (the simple-resource record modals, relation-manager attach forms, and view-modal infolists) do this automatically.

vue
<KinetixModal :open="open" title="New event" scroll-body>
    <KinetixForm :form="eventForm" flat @submit="submit" />
</KinetixModal>

Submitted from outside: id and hide-submit

A page footer, a header or a modal's footer can own the Save button. Give the form an id and the action ->submits('that-id') (Actions); hide-submit drops the form's own button so there's only one:

vue
<KinetixForm id="adjust-stock" :form="form" hide-submit @submit="save" />

Long forms in a modal: scroll-body is a layout choice, not a rescue

The shell bounds itself. A panel taller than the viewport scrolls inside the dialog wrapper, so a long form never strands its first field or its footer actions off screen — with or without scroll-body.

Pass scroll-body when you want the other layout: header and footer pinned while only the body scrolls (in a shadcn ScrollArea), so Save and Cancel stay visible the whole way down. Skip it when the body hosts a dropdown that renders in flow and must escape the panel — a scrolling body clips it. KinetixSheet always uses the pinned layout.

A 16-field form in a modal: the panel is bounded to the viewport, the title and the Cancel/Save footer stay pinned, and the body scrollsA 16-field form in a modal: the panel is bounded to the viewport, the title and the Cancel/Save footer stay pinned, and the body scrolls

Forms sit near the top: placement="top"

A shadcn dialog is vertically centered, which is right for a confirmation but awkward for a form you work in: a centered panel grows in both directions, so every height change — the loading skeleton giving way to the fields, a validation error appearing, a conditional field revealing itself — re-centers the panel and moves the title and the field under your cursor. Anchored to the top it only grows downward.

Pass placement="top" to anchor the panel 10vh from the top on sm and up (mobile stays centered, the keyboard owns that layout). Kinetix's record modals — create / edit / view in tables, simple resources and relation managers, the relation-manager attach picker, the role editor and the announcement form — use it by default; short dialogs (confirmations, deletes) stay centered. With scroll-body the panel's height budget shrinks by the same offset, so the pinned footer never drops below the fold.

vue
<KinetixModal :open="open" title="New event" scroll-body placement="top">
    <KinetixForm :form="eventForm" flat @submit="submit" />
</KinetixModal>

3. Fieldset ​

A lighter labelled grouping than Section — a bordered <fieldset> with a <legend>, no card chrome. Supports columns() and nesting.

Fieldset layoutFieldset layout
php
use Happones\Kinetix\Forms\Components\Fieldset;

Fieldset::make('Address')
    ->columns(6)
    ->schema([
        TextInput::make('city')->columnSpan(4),
        TextInput::make('zip')->columnSpan(2),
    ])

4. Tabs ​

A tabbed container (Reka UI). Each Tab holds its own schema and may carry an icon() (any Kinetix icon name). Fields inside every tab are still validated and saved — switching tabs is purely visual.

Tabs layoutTabs layout
php
use Happones\Kinetix\Forms\Components\Tabs;
use Happones\Kinetix\Forms\Components\Tab;

Tabs::make()->tabs([
    Tab::make('Profile')->schema([
        TextInput::make('name'),
    ]),
    Tab::make('Security')->icon('settings')->schema([
        TextInput::make('password'),
    ]),
])

5. Split ​

A responsive flex row: children sit side-by-side from the md breakpoint and stack on small screens.

Split layoutSplit layout
php
use Happones\Kinetix\Forms\Components\Split;

Split::make([
    TextInput::make('first_name'),
    TextInput::make('last_name'),
])

6. Placeholder ​

A read-only display block (label + content). It holds no field state and is never validated or saved — handy for showing computed/derived info inside a form.

Placeholder (read-only) fieldPlaceholder (read-only) field
php
use Happones\Kinetix\Forms\Components\Placeholder;

Placeholder::make('Account ID')
    ->content(fn ($record) => $record?->id ?? '—')

All layout components share the base columnSpan(), visible()/hidden(), visibleOn()/hiddenOn(), and authorize() methods (see §5 Operations & Visibility Constraints), and nest arbitrarily.

7. Wizard ​

Break a form into validated steps with Wizard::make()->steps([Step::make(...)]). Advancing is gated on the current step's required fields. Also available as a standalone page component with gating middleware — see the dedicated Wizard guide.

php
use Happones\Kinetix\Forms\Components\Wizard;
use Happones\Kinetix\Forms\Components\Step;

Wizard::make()
    ->variant('gradient')
    ->steps([
        Step::make('Account')->schema([TextInput::make('email')->required()]),
        Step::make('Profile')->icon('user')->schema([TextInput::make('name')]),
    ])

4. Fields & Form Inputs API ​

What the rules ask for, before the server ​

A field's validation rules show up in the form itself, so the user learns what a value needs before submitting it:

php
TextInput::make('code')->rules(['alpha_dash', 'lowercase', 'max:20']),
NumberField::make('max_price')->rules(['numeric', 'gte:min_price']),
TextInput::make('password')->password()->rules([Password::min(10)->mixedCase()->numbers()]),
TextInput::make('password_confirmation')->password()->rules(['same:password']),
  • Hints under the field say what the rules ask, in the user's language: "Letters, numbers, dashes and underscores", "Between 1 and 100", "At least Min price", "PNG, JPG · Up to 2 MB · At least 800×600 px", a password policy's parts.
  • A counter (12/20) shows a capped length. It reads the limit aloud only near it, and never stops the typing: pasted text isn't cut silently.
  • Checks when the user leaves the field, then live while they fix it. The message is the requirement the value misses, so it says what to do. A server or Precognition error wins; an empty field is left to the server.
  • Comparisons with other fields (gt, gte, lt, lte, same, different, after:start_date) use their current values, and a confirmation that matches says so.
  • Dedicated inputs where a rule calls for one:
    • A password field (->password()) has a show/hide toggle. With a Password rule, the policy is a checklist that ticks off as the user types, with a strength meter.
    • A code (ip, ipv4, ipv6, mac_address, uuid, ulid, hex_color) is shown in a monospace font, with a tick once it's well formed.
    • A date picker can't pick what after/before/date_equals refuse, including bounds set by another field (before:ends_on).
    • A file upload checks types, size and dimensions before sending (FileUpload).
  • The right keyboard and no autocorrect: numbers open the number pad, email/url their keyboards, and codes (alpha_dash, uuid, ip, lowercase, …) turn off autocapitalization and spellcheck. lowercase and uppercase convert the text as it's typed.
  • Conditional rules become live conditions: required_if:type,company, required_unless, required_with:field and required_without:field make the field required as the form changes; exclude_if, exclude_unless and prohibited_if hide it. A condition you set yourself (requiredWhen()) wins.
Rule familyIn the form
min/max/size/betweenlength (text), value (numbers), count (lists), size (files)
integer, decimal, multiple_of, digits, digits_betweenhint and check
alpha, alpha_num, alpha_dash, ascii, lowercase, uppercasehint and check
starts_with, ends_with, doesnt_start_with, doesnt_end_with, not_inhint and check
email, url, ip, ipv4, ipv6, mac_address, uuid, ulid, hex_color, json, timezonehint (an example) and check
regex, not_regexcheck, when the pattern translates to JavaScript
after, after_or_equal, before, before_or_equal, date_equalshint and check, against a date, today/tomorrow or another field
gt, gte, lt, lte, same, differenthint and check against another field
distincthint and check
Password::min()->letters()->mixedCase()->numbers()->symbols()one hint per part, each checked; uncompromised() is checked on save
mimes, extensions, mimetypes, image, File::types(), dimensionsone summary hint

The server validates everything again: these checks are a guide, loose where the exact rule is the server's (email, url), and rules that need the database (unique, exists) or run your code (closures, custom rule objects) are only checked there. A form built from a FormRequest (fields from a FormRequest) carries the request's rules, so its hints follow them too.

Fields reside in the Happones\Kinetix\Forms\Components namespace. They all inherit from the base Field class, providing rich configuration options:

Shared Field Methods ​

  • label(string|Closure $label): Customizes field title. If omitted, Kinetix translates the column name into a TitleCase Headline. Wrap an explicit label in __() to keep it translatable — ->label(__('posts.fields.title')) (see Localizing labels).
  • default(mixed $value|Closure $value): Set initial fallback state.
  • disabled(bool|Closure $condition = true): Prevent input mutations.
  • placeholder(string|Closure $placeholder): Visual watermark.
  • helperText(string|Closure $text): Hint rendered under the field. Wired for assistive tech: it gets an addressable id ({name}-help) and the control's aria-describedby chains it together with any validation error ({name}-help {name}-error), so screen readers hear both.
  • prefix(string|Closure $prefix): Prepends a label prefix.
  • suffix(string|Closure $suffix): Appends a label suffix.
  • extraInputAttributes(array $attributes): Custom HTML attributes merged onto the input element.
  • extraAttributes(array $attributes): Custom HTML attributes merged onto the field's outer container.
  • extraFieldWrapperAttributes(array $attributes): Custom HTML attributes merged onto the field wrapper (label + control group).

Conditional fields ​

Show, hide, require or disable a field based on another field's live value — evaluated client-side (no round-trip) and enforced again on the server:

  • visibleWhen(string $field, mixed $value = true, string $operator = 'equals')
  • hiddenWhen(string $field, mixed $value = true, string $operator = 'equals')
  • requiredWhen(string $field, mixed $value = true, string $operator = 'equals')
  • disabledWhen(string $field, mixed $value = true, string $operator = 'equals')
php
Select::make('type')->options(['person' => 'Person', 'company' => 'Company']);
TextInput::make('company_name')->visibleWhen('type', 'company');
TextInput::make('vat')->visibleWhen('country', ['ES', 'FR'], 'in');
TextInput::make('reason')->requiredWhen('status', 'rejected');

Operators: equals, notEquals, in, notIn, truthy, falsy, filled, blank (any other name throws, and in/notIn need a list). The condition ships as plain data (not a closure), so KinetixForm re-evaluates it instantly as the user types. The server mirrors it: a conditionally hidden field is excluded from validation and never persisted (a smuggled value never reaches the model), and a conditionally required one gains required on submit — the client is a UX layer, not the guard.

The browser and the server compare values the same way:

OperatorMatches when
equals / notEqualsAgainst true/false, the value is read as a boolean (an untouched toggle is false; '1', 'on' and 1 are true). Otherwise as text, so 1 equals '1' and null equals ''. A list value (checkboxes, a multi-select) matches when it contains the value; a key/value map never matches.
in / notInThe value is one of the list; a list value matches when it shares any member with it.
truthy / falsyFalsy: null, false, 0, '', '0', 'false' and an empty list or map.
filled / blankBlank: null, a whitespace-only string and an empty list or map. Numbers and booleans are always filled.

Server-driven reactivity ($get / $set) ​

For logic the client can't do alone — recomputing a select's options from the database, prefilling fields — mark a field ->live() and let other fields read the state through $get (and push values back through $set):

php
use Happones\Kinetix\Forms\Support\Get;
use Happones\Kinetix\Forms\Support\Set;

Select::make('country')
    ->live()
    ->options(Country::pluck('name', 'id'))
    ->afterStateUpdated(fn (Set $set) => $set('state', null)); // clear dependent

Select::make('state')
    ->options(fn (Get $get) => State::where('country_id', $get('country'))->pluck('name', 'id'));

When country changes, KinetixForm POSTs the current values to a signed endpoint, the server rebuilds the form and recomputes the schema (so state's options reflect the new country), and afterStateUpdated clears the stale state. Only the hooks of the live fields that changed run, so a cascade (country clears state, state clears city) holds: picking a state doesn't re-run the country's hook. The round-trip is debounced, a response overtaken by a newer change is discarded, and if the swap blurs the field you were in, focus and caret go back to it.

$get sees the form's values on the first render too, so an edit form opens with its dependent options already resolved.

  • live(onBlur: true) recomputes when focus leaves the field, and live(debounce: 1000) waits that long after the last change; plain live() waits 300ms.
  • $set('items.0.qty', 5) updates just that value; the rest of items stays.
  • The endpoint is rate limited per user (kinetix.forms.recompute_throttle, default 120,1: 120 requests a minute). The limit has its own counter, so it doesn't share one with your app's throttle routes. A third segment (120,1,my-prefix) names that counter yourself.
  • A wizard's "Next" checks the step's required fields as they are shown: a field its condition hides doesn't block, and a requiredWhen field blocks while its condition holds. A Repeater checks its own required() and minItems(); the fields inside its items are validated on submit.
  • A Repeater item stores only the fields its own conditions show; conditions inside an item read that item's values.

A closure receives what it asks for by type or name: Get $get / $get, Set $set / $set, Model $record / $record (a legacy fn ($record) still works).

Reactivity needs a reconstructible form. The server re-runs the form's own closures, so the form must be rebuildable: a Form subclass (app/Kinetix/Forms/…) or a resource form (Resource::form()). An anonymous inline Form::make()->schema([...]) ships no recompute descriptor — its live() fields render but don't recompute. Make it a class (or call ->reactiveVia(Resource::class)) to opt in. Conditional fields above (visibleWhen etc.) work on any form, inline included — they need no round-trip.


1. TextInput ​

Renders an HTML <input> tag. Supports several semantic modifications:

php
use Happones\Kinetix\Forms\Components\TextInput;

TextInput::make('user_email')
    ->email() // sets type="email"
    ->required()
    ->placeholder('you@example.com');

Type Modifiers ​

  • password(): Changes type to password to conceal characters.
  • email(): Sets input type to email for device keyboards.
  • numeric(): Enforces numeric keyboard and appends numeric validation rule.
  • url(): Sets type to url and registers url validation.

2. Select ​

Select fieldSelect field

Renders an HTML dropdown <select>. Supports static arrays, closures, and direct PHP Enum reflections.

php
use Happones\Kinetix\Forms\Components\Select;
use App\Enums\UserRole;

Select::make('role')
    ->options(UserRole::class)
    ->required();

Option Resolvers ​

  • Enum Reflection: Pass the classname of any Enum — see Enums & HasLabel below.
  • Array Mapping: Pass a simple key-value array:
    php
    Select::make('tier')
        ->options([
            'free' => 'Free Tier',
            'pro' => 'Professional Account',
        ]);
  • Closure Resolver: Evaluate options dynamically based on the current model record:
    php
    Select::make('manager_id')
        ->options(fn (?User $record) => User::where('id', '!=', $record?->id)->pluck('name', 'id')->toArray());

Enums & HasLabel ​

Pass an Enum class straight to options() and Kinetix builds the option list from its cases. Labels resolve in this order:

  1. getLabel(), when the enum implements Happones\Kinetix\Support\Contracts\HasLabel — or simply defines a public getLabel(): ?string method. Resolution is duck-typed, so enums written against an equivalent third-party label contract work in Kinetix unchanged.
  2. Otherwise the backing value (backed enums) or the case name (pure enums).
php
use App\Enums\TaskStatus;

Select::make('status')
    ->options(TaskStatus::class)
    ->default(TaskStatus::Todo->value);
php
namespace App\Enums;

use Happones\Kinetix\Support\Concerns\HasLabelOptions;
use Happones\Kinetix\Support\Contracts\HasLabel;

enum TaskStatus: string implements HasLabel
{
    use HasLabelOptions;

    case Todo = 'todo';
    case Done = 'done';

    public function getLabel(): ?string
    {
        return match ($this) {
            self::Todo => __('To do'),
            self::Done => __('Done'),
        };
    }
}

The optional HasLabelOptions concern adds a static TaskStatus::options() helper (['todo' => 'To do', 'done' => 'Done']) for the places where you need the array yourself.

Define the enum once and it drives every surface: Select, Radio, TextColumn->badge(), SelectColumn, SelectFilter, Infolist entries, and export columns. See Tables → Enum Support & Contracts for the color and icon contracts (HasColor, HasIcon) used by badges and icon columns.

Searchable (combobox) ​

Call ->searchable() to render the field as a combobox with a search box (Reka UI Combobox) instead of a plain dropdown. With only options(), the search filters them client-side:

php
Select::make('country')->searchable()->options($countries);

For large datasets, search a model remotely with ->searchUsing() — the client queries the server as you type (debounced + lazy, fetched only when the field is opened), so you never ship thousands of options:

php
Select::make('author_id')
    ->searchable()
    ->searchUsing(
        model: \App\Models\Author::class,
        labelColumn: 'name',
        searchColumns: ['name', 'email'],   // columns the server LIKE-matches
        valueColumn: 'id',
    );

The model + columns are encrypted into a signed token (the query string can never name an arbitrary table/column — same guard as inline table edits), and the currently-selected option's label is resolved server-side so it shows immediately. Remote search hits POST {prefix}/forms/search.

From a relationship ​

The relation already names the related model and its key, so ->relationship() replaces repeating them (the same API SelectFilter exposes):

php
Select::make('author_id')->relationship('author', 'name');

// Narrow the options:
Select::make('author_id')
    ->relationship('author', 'name', fn ($query) => $query->where('active', true));

Inherited by CheckboxList and Radio — the three option-backed fields — so a BelongsToMany renders as a checkbox list with one line. Kinetix resolves the options; persisting a pivot stays your job in mutateFormDataBeforeSave() or the controller.

The owning model comes from the form: Form::make()->model(Article::class), or inferred when you fill() it with a record. Without one the relation can't be resolved and the field falls back to whatever options() holds — it never throws.

Combining it with search

->relationship(...)->searchable() derives the remote-search token from the relation, so the two can't disagree about the model or the label column.

The query modifier travels with it only as the class-string of an invokable class — the token round-trips through the browser, and a closure can't be serialized:

php
Select::make('author_id')
    ->relationship('author', 'name', \App\Kinetix\ActiveAuthors::class)
    ->searchable();

A closure still shapes the eagerly-loaded options; it just doesn't reach the search endpoint. (Same reasoning as the config callbacks — see Membership.)

Eager options are capped

Without searchable(), a relationship() loads the related rows into the page payload, capped at kinetix.forms.relationship_options_limit (200). Past the cap the list is truncated and a warning is logged — declare the field searchable() so options are fetched on demand instead of shipping a table to the browser.

Scope remote search in a multi-tenant app

A searchable Select queries the related model directly, so with no bound it will happily return every tenant's labels, 20 rows per request. The search descriptor is bound to the user and team it was minted for and expires (kinetix.tables.token_ttl), but that stops replay — not a legitimate user searching too widely. Declare the tenant key:

php
Select::make('project_id')
    ->relationship('project', 'name')
    ->searchable()
    ->searchScope(['team_id' => $request->user()->currentTeam->getKey()]);

searchScope() takes plain equality constraints. For anything else, use the invokable query-modifier class shown above.


3. Checkbox ​

Renders a custom toggle checkbox. To match design standards, it automatically maps to our custom frontend <KinetixCheckbox> component instead of browser default checkboxes.

php
use Happones\Kinetix\Forms\Components\Checkbox;

Checkbox::make('terms_accepted')
    ->label('I accept the license agreement')
    ->required();

4. Toggle ​

Renders a modern, animated toggle switch. Perfect for binary configurations.

php
use Happones\Kinetix\Forms\Components\Toggle;

Toggle::make('is_active')
    ->label('Account Status')
    ->default(true);

5. Textarea ​

Renders an HTML <textarea> for multi-line inputs.

php
use Happones\Kinetix\Forms\Components\Textarea;

Textarea::make('bio')
    ->rows(4) // Adds row count attributes
    ->maxLength(500);

6. DatePicker & DateTimePicker ​

Both render the shadcn calendar by default (Reka UI popover) — DatePicker a single-date calendar, DateTimePicker a calendar plus scrollable hour/minute button columns. Call ->native() to fall back to a plain native <input type="date"> / datetime-local.

Date picker calendarDate picker calendar

DateTimePicker (popover open) — calendar + hour/minute columns:

Date-time picker showing the calendar and time columnsDate-time picker showing the calendar and time columns
php
use Happones\Kinetix\Forms\Components\DatePicker;
use Happones\Kinetix\Forms\Components\DateTimePicker;

DatePicker::make('published_at')->locale('es');          // shadcn calendar
DatePicker::make('published_at')->native();              // native input

DateTimePicker::make('scheduled_at')
    ->label('Schedule Release')
    ->minuteStep(15)     // 15-minute increments
    ->twelveHour()       // 12h clock with an AM/PM column
    ->confirm();         // commit only via the Apply button (draft until then)
MethodApplies toDescription
->native()bothRender the native input instead of the shadcn calendar
->locale(string)bothBCP-47 calendar locale ('es', 'fr', 'en-US') — defaults to the application locale (app()->getLocale(), es_MX → es-MX); also the default for MonthPicker/WeekPicker/DateRangePicker calendars and NumberField number formatting
->minuteStep(int)DateTimePickerMinute granularity for the time column (default 5)
->twelveHour()DateTimePicker12-hour clock with an AM/PM column
->confirm()bothExplicit-commit variant: clicks build a draft inside the popover, the footer button becomes Apply (the only thing that emits), and dismissing any other way (outside click / Escape) discards the draft
->todayButton()DatePickerShow a Today shortcut in a popover footer
->closeOnSelect(bool)DatePickerWhether picking a date closes the popover (default true — the shadcn behavior); pass false to keep it open
->typed() / ->typed(string $format)both (+ TimePicker, MonthPicker)Type the value on the keyboard over a mask, the picker on a button to the right — see Typed input

Timezones: app.timezone by default, per-field override ​

Picker values are naive wall-time strings (Y-m-d, H:i, Y-m-dTH:i) whose implicit timezone is Laravel's app.timezone — exactly what Carbon::parse() assumes when the value reaches the server. Everything that computes "now" client-side honors that: the Today/Now presets and the calendar's initial month read the clock in the app timezone (shared via kinetix_config.timezone), never the browser's — a viewer in Madrid using a Mexico City app gets Mexico City's today/now, not their own.

Override it per field when one input means a different zone:

php
DateTimePicker::make('scheduled_at')->timezone('America/Mexico_City');
TimePicker::make('opens_at')->timezone('Asia/Tokyo');
DatePicker::make('published_at')->timezone('UTC');

The standalone Vue components accept the same timezone prop (an IANA name); without it they use the shared app timezone, and outside an Inertia app they fall back to the browser clock. Invalid names never throw — they fall back too.

What this is (and isn't). The field stores wall time in the effective timezone; with the default (app.timezone) that round-trips into Carbon and your database with no conversion at all. A per-field ->timezone() means "this input's wall time is in that zone" — if it differs from app.timezone, convert on save (Carbon::parse($value, 'Asia/Tokyo')->setTimezone(config('app.timezone'))). The pickers deliberately do not silently convert values between zones.

Typed input: keyboard entry over a mask ​

For people who capture dates all day, clicking through a calendar is the slow path. ->typed() turns the trigger into a masked text input: the reader types digits, the separators of the format appear as they go, and the calendar (or clock, or month grid) stays one click away on a button to the right. It works on DatePicker, DateTimePicker, TimePicker and MonthPicker.

php
DatePicker::make('born_on')->typed('dd/mm/yyyy');              // 15/03/2026
DatePicker::make('invoiced_on')->typed('mm/dd/yyyy');          // 03/15/2026
DatePicker::make('shipped_on')->typed('yyyy-mm-dd');           // 2026-03-15
DateTimePicker::make('starts_at')->typed('dd/mm/yyyy HH:mm');  // 15/03/2026 19:05
DateTimePicker::make('starts_at')->twelveHour()->typed();      // locale order, 12h clock
TimePicker::make('opens_at')->typed('hh:mm a');                // 07:05 PM
MonthPicker::make('period')->typed('mm/yyyy');                 // 03/2026
DatePicker::make('born_on')->typed();                          // the reader's locale order
TokenMeans
ddDay, two digits
mmMonth in the date part, minutes in the time part
yyyy / yyYear, four or two digits (yy reads as 20yy)
HH / hhHour on a 24-hour / 12-hour clock
aAM/PM — typed as a or p
anything elseA separator, inserted for the reader

The time part starts at the first space or at the first hour token, so dd/mm/yyyy HH:mm reads its second mm as minutes.

  • The stored value doesn't change. Whatever the format, the field still holds the ISO value (Y-m-d, Y-m-dTH:i, H:i, Y-m), so your validation rules and casts are the same as without ->typed().
  • No format = the reader's locale order. ->typed() alone follows the field's locale (dd/mm/yyyy for es-MX, mm/dd/yyyy for en-US, dd.mm.yyyy for de-DE); the time part follows the field's clock (->twelveHour()), not the locale's.
  • Typing helps. A first digit no two-digit value can start with is padded (4 in the day becomes 04/), letters are ignored, and AM/PM is filled from a or p. On phones the keypad is numeric unless the format has an AM/PM part.
  • It says what's wrong, under the field. A date that doesn't exist (31/02/2026), a time that doesn't (25:00), a date outside minValue()/maxValue() or the rules' bounds (after:today), and — on leaving the input — a half-typed value each get a message. While the text isn't a usable value, the field holds null, so a stale value never submits behind what the reader sees; a required rule catches it.
  • A format that can't be filled is refused where you write it.DatePicker::make('d')->typed('dd/mm') throws an InvalidArgumentException naming the missing part, as does hh without a.

The standalone Vue components take the same option as props: <KinetixDatePicker typed input-format="dd/mm/yyyy" /> (and on KinetixDateTimePicker, KinetixTimePicker, KinetixMonthPicker). The mask on its own is <KinetixMaskedDateInput kind="date" format="dd/mm/yyyy" v-model:value="date" />, with a button slot for whatever goes on its right.

  • DatePicker closes on select (one decision = done — the shadcn default), and shows no footer unless you opt into ->todayButton() or ->confirm().
  • DateTimePicker deliberately does NOT close on date select — a time is still pending. It always shows a footer: Now (current date + time, rounded to minuteStep) and Done, the explicit dismissal a multi-part picker needs (never rely on outside-click alone). Live mode commits every click as it happens; ->confirm() turns Done into Apply and defers the commit to it.
  • On mobile the panel stacks (calendar above the time columns), caps at 80dvh with internal scrolling, and the time buttons grow to touch-size (~40px rows) — desktop keeps the compact sm: layout.

TimePicker ​

A time-only field (no date). Renders an input-style trigger that opens a popover with scrollable hour/minute (+ AM/PM) columns, storing an H:i string (e.g. "14:30"). Defaults to a 12-hour clock with AM/PM — call ->twentyFourHour() for 24-hour. ->native() renders <input type="time">.

Time picker — input trigger + popover columnsTime picker — input trigger + popover columns
php
use Happones\Kinetix\Forms\Components\TimePicker;

TimePicker::make('opens_at');                       // 12h + AM/PM (default)
TimePicker::make('opens_at')->twentyFourHour();     // 24h clock
TimePicker::make('opens_at')->minuteStep(15);       // 15-minute increments
TimePicker::make('opens_at')->native();             // native <input type="time">
TimePicker::make('opens_at')->confirm();            // commit only via Apply

Like DateTimePicker, the popover has a footer: Now (current time rounded to minuteStep) and Done — or Apply with ->confirm(), which defers the commit to that button and discards the draft on any other dismissal.

BusinessHours (weekly schedule) ​

A weekly business-hours editor — one row per day with an enable switch, one or more HH:MM time ranges, add/remove range and Apply to all days. Day names come from Intl in the active locale. Defaults to Monday–Friday 09:00–17:00, and the structure is validated server-side automatically (the kinetix_weekly_schedule rule, seeded by the field):

php
use Happones\Kinetix\Forms\Components\BusinessHours;

BusinessHours::make('hours');

Times are picked with the same picker as TimePicker (12-hour with AM/PM by default), each one named for screen readers ("Monday opens at, 09:00 AM"):

php
BusinessHours::make('hours')->twentyFourHour()->minuteStep(15);

Pair the column with the AsWeeklySchedule cast to read it back as a value object:

php
use Happones\Kinetix\Support\Casts\AsWeeklySchedule;

protected $casts = ['hours' => AsWeeklySchedule::class];
php
$venue->hours?->isOpenAt(now($venue->timezone));   // bool — pass the moment in ITS timezone
$venue->hours?->isOpenNow($venue->timezone);       // shortcut
$venue->hours?->effectiveSchedule();               // full normalized week array
$venue->hours?->rangesFor('monday');               // enabled ranges (empty when off)
  • A range whose end is at or before its start wraps past midnight — friday 22:00–02:00 keeps saturday 01:00 open through friday's entry.
  • The stored shape is { monday: { enabled, ranges: [{ start, end }] }, … } with all seven days always present; disabled days keep their last ranges so re-enabling restores them.
  • WeeklySchedule::fromArray() normalizes loose input (invalid ranges dropped, unknown keys removed); the validation rule is the STRICT gate — also usable standalone: 'hours' => ['array', 'kinetix_weekly_schedule'].

MonthPicker, YearPicker & WeekPicker ​

Coarser date pickers. Each renders a shadcn popover by default and accepts ->native() for the browser-native input, plus ->minValue() / ->maxValue() bounds (mapped to the native min/max).

FieldStoresShadcn UINative input
MonthPickerY-m ("2026-06")month grid + year nav<input type="month">
YearPickerY ("2026")paginated year grid<input type="number">
WeekPickero-\WW ("2026-W25")calendar — clicking a day highlights its whole week<input type="week">

All three share the picker behavior contract: ->confirm() (draft + Apply-only commit), ->todayButton() (a "This month" / "This year" / "This week" shortcut), ->closeOnSelect(false), and ->timezone() — the current-period preset and the initial view read the clock in app.timezone by default, never the browser's (see Timezones). The standalone Vue components accept the same props.

Month pickerMonth pickerYear pickerYear pickerWeek pickerWeek picker
php
use Happones\Kinetix\Forms\Components\{MonthPicker, YearPicker, WeekPicker};

MonthPicker::make('billed_month')->minValue('2026-01')->maxValue('2026-12');
YearPicker::make('fiscal_year')->minValue('2020')->maxValue('2030');
WeekPicker::make('sprint')->startWeek(0);   // week starts Sunday (0=Sun … 6=Sat)
WeekPicker::make('sprint')->native();       // native <input type="week">

WeekPicker / WeekFilter take ->startWeek(int) (0=Sunday … 6=Saturday, default Monday) since the first day of the week is region-dependent.

The same three are available as table filters — see Tables → Filters (MonthFilter, YearFilter, WeekFilter).


DateRangePicker ​

Pick a start + end date. Stores { from, to } (each an ISO Y-m-d string). Renders the shadcn range calendar in a popover by default, or two native date inputs via ->native(). Supports ->numberOfMonths(), ->weekdayFormat(), ->fixedWeeks(), ->locale(), and ->minValue() / ->maxValue() bounds.

Date range pickerDate range picker
php
use Happones\Kinetix\Forms\Components\DateRangePicker;

DateRangePicker::make('period')->numberOfMonths(2);
DateRangePicker::make('period')->minValue('2026-01-01')->maxValue('2026-12-31');
DateRangePicker::make('period')->native();   // two native <input type="date">

// Same behavior variants as the other pickers:
DateRangePicker::make('period')->confirm();            // commit only via Apply
DateRangePicker::make('period')->todayButton();        // Today (from = to = today)
DateRangePicker::make('period')->closeOnSelect(false); // stay open when complete
DateRangePicker::make('period')->timezone('America/Mexico_City');

By default the popover closes once both ends are picked; ->confirm() switches to the draft + Apply contract, ->todayButton() adds a Today shortcut, and — like every picker — "today" is read in app.timezone (overridable per field with ->timezone()), never the browser clock. See Timezones and Close behavior above.

Persist {from,to} to a JSON-cast column, or split it in a dehydrateStateUsing() callback. As a table filter, use DateRangeFilter (see Tables → Filters).


AddressPicker ​

A structured address field. Stores { line1, line2, city, state, postalCode, country } and renders a text input per part plus a searchable country select sourced from a built-in ISO 3166-1 list. Limit/reorder the sub-fields with ->fields(), hide one or more with ->except(), or replace the country options with ->countries().

Address pickerAddress picker
php
use Happones\Kinetix\Forms\Components\AddressPicker;

AddressPicker::make('address');                              // all six sub-fields
AddressPicker::make('address')->fields(['line1', 'city', 'country']);
AddressPicker::make('address')->except('country');           // hide the country select
AddressPicker::make('address')->except(['line2', 'country']);
AddressPicker::make('address')->countries(['US' => 'United States', 'MX' => 'Mexico']);

Persist the value to a JSON-cast column, or split it into separate columns in a dehydrateStateUsing() callback. As a table filter, use AddressFilter (see Tables → Filters).


RichEditor ​

A rich text / WYSIWYG field with three swappable editor drivers. The default driver comes from config('kinetix.forms.rich_editor'); override per field with ->editor() or the ->basic() / ->tiptap() / ->markdown() shortcuts.

DriverDependencyStoresNotes
basic (default)noneHTMLcontenteditable + toolbar; works out of the box
tiptap@tiptap/core + @tiptap/starter-kit (MIT)HTMLthe headless WYSIWYG standard, styled with your shadcn tokens; loaded lazily
markdownnoneMarkdowntextarea + live preview
Rich editor — basic driverRich editor — basic driver
php
use Happones\Kinetix\Forms\Components\RichEditor;

RichEditor::make('body');               // config default (basic)
RichEditor::make('body')->tiptap();     // rich WYSIWYG (opt-in dependency)
RichEditor::make('notes')->markdown();  // Markdown source + preview

To use the Tiptap driver, install its packages and register them in your entry file — the import must live in your module graph, because no import shape inside a published component can be optional at build time and still resolve at runtime:

bash
npm install @tiptap/core @tiptap/starter-kit
ts
// resources/js/app.ts
import { registerKinetixTiptap } from '@/composables/useKinetixRichEditorEngine';

registerKinetixTiptap(async () => ({
    core: await import('@tiptap/core'),
    starterKit: await import('@tiptap/starter-kit'),
}));

The loader uses dynamic imports, so Tiptap stays code-split — it downloads only when a ->tiptap() field actually renders. Apps that never register keep building and running unaffected; a ->tiptap() field without a registration shows an inline notice with these instructions.

Rich editor — Tiptap driverRich editor — Tiptap driver

HTML is not sanitized server-side

The basic / tiptap drivers store raw HTML. Kinetix does not sanitize it — escape or sanitize on output (Blade escapes by default; only v-html / {!! !!} render it raw). Run user HTML through a sanitizer (e.g. HTMLPurifier) if untrusted.

Rich editor — Markdown driverRich editor — Markdown driver

NumberField ​

A numeric input with increment / decrement stepper buttons, min / max / step bounds and Intl.NumberFormat formatting (decimal, percent or currency). Built on Reka UI's NumberField.

Number field — decimal, percent, currencyNumber field — decimal, percent, currency
php
use Happones\Kinetix\Forms\Components\NumberField;

NumberField::make('quantity')->min(0)->max(99)->step(1);
NumberField::make('rate')->percent()->decimals(0, 2);   // 0–2 fraction digits
NumberField::make('price')->currency('USD');
NumberField::make('weight')->step(0.1)->numberLocale('de-DE');

It stores a plain number (or null when cleared). As an inline-editable table column, use NumberInputColumn (see Tables → Editable columns).


Slider, Rating & PinInput ​

Three focused value inputs.

Slider, Rating and PIN inputSlider, Rating and PIN input

Slider — a single-value range slider (Reka UI), storing a number:

php
use Happones\Kinetix\Forms\Components\Slider;

Slider::make('volume')->min(0)->max(100)->step(5);

Rating — a star rating storing 0..max (click the current value to clear):

php
use Happones\Kinetix\Forms\Components\Rating;

Rating::make('score')->max(5);
Rating::make('score')->max(10)->allowHalf();   // half-stars

PinInput — a segmented PIN / OTP input (Reka UI), storing the joined string:

php
use Happones\Kinetix\Forms\Components\PinInput;

PinInput::make('code')->length(6)->otp();          // one-time-code autofill
PinInput::make('pin')->length(4)->numeric()->mask();

SlugInput & SignaturePad ​

Slug input and signature padSlug input and signature pad

SlugInput — a URL-slug text input. Point from() at a sibling field and the slug is generated live from it until the user edits the slug manually:

php
use Happones\Kinetix\Forms\Components\SlugInput;

SlugInput::make('slug')->from('title');
SlugInput::make('slug')->from('name')->separator('_');

SignaturePad — a canvas the user signs on (mouse, touch or pen). Stores a PNG data URL; a Clear button resets it. Persist it to a TEXT column, or decode + store it as a file in dehydrateStateUsing().

php
use Happones\Kinetix\Forms\Components\SignaturePad;

SignaturePad::make('signature');
SignaturePad::make('signature')->penColor('#1d4ed8')->height(180);

GeneratorInput ​

A text input with a one-click VALUE GENERATOR beside it — passwords, PINs, usernames, tokens, or any custom charset. Generation is client-side and crypto-strong (uniform, no modulo bias), so nothing round-trips the server. It ships a catalog of presets (compose by data, add one in a single place) plus a fully custom escape hatch.

php
use Happones\Kinetix\Forms\Components\GeneratorInput;

// Password — toggle classes, exclude look-alikes, copy + mask affordances
GeneratorInput::make('password')->password(length: 20)->symbols(false)->excludeAmbiguous()->copyable()->masked();

// PIN — numeric | alphanum | alpha
GeneratorInput::make('pin')->pin(length: 6, mode: 'numeric');

// Username — from a pattern of SIBLING fields (followed live until edited),
// normalized to a safe handle; falls back to a random handle until they fill in
GeneratorInput::make('username')->username()->pattern('{first_name}.{last_name}')->separator('.');
MethodEffect
password(length) / pin(length, mode) / username(length)Preset + kind
length(n)Characters to generate
lowercase() / uppercase() / digits() / symbols()Toggle a password class (pass false to drop it)
symbolSet('…')Override the symbol alphabet
excludeAmbiguous()Drop look-alikes (O/0, l/1/I, …)
pattern('{a}.{b}') / separator('.')Username from sibling fields
copyable()Click-to-copy beside the generate button
masked()Hide the value behind a reveal toggle

Presets ​

Pick a ready-made generator by name — explicit knobs set afterwards still win (->preset('password-strong')->length(24)):

php
GeneratorInput::make('token')->preset('api-key');       // sk_… 40 base62
GeneratorInput::make('serial')->preset('license-key');  // XXXX-XXXX-XXXX-XXXX
GeneratorInput::make('id')->preset('uuid');             // RFC-4122 v4
GeneratorInput::make('nick')->preset('handle-memorable'); // brave-otter-42
GroupPresets
Passwordspassword-strong, password-simple, password-memorable, passphrase
PINs / codespin-4, pin-6, otp, pin-alphanum
Usernameshandle, handle-memorable (+ username()->pattern(…))
Tokens / idsuuid, hex, hex-64, nanoid, api-key, license-key, slug

An unknown name throws (GeneratorInput::PRESETS lists them all), so a typo fails where you wrote it instead of silently generating something else.

How strong each password preset is:

PresetShapeStrength
password-strong16 characters, every class~103 bits; uses each of lower, upper, digit and symbol at least once
password-simple14 letters and digits, no look-alikes~81 bits; uses each enabled class at least once
passphrase6 words (brave otter calm river …)~51 bits
password-memorable4 words + 2 digits~41 bits

handle and handle-memorable make handles, not secrets (~24–52 bits), and nothing checks them for collisions: add a unique rule to the field.

A username pattern (username()->pattern('{first_name}.{last_name}')) fills the field in as the user types the sibling fields, and keeps following them until the user types a username of their own.

The value is generated in the browser, so treat it like any input: validate it on the server, and hash a password before you store it (a hashed cast). An edit form never sends an attribute its model keeps out of serialization back to the browser: one in $hidden such as password, one left out of a $visible whitelist, or a related model's (owner.password). It renders empty, without the field's default(), and saved untouched (blank, or off for a toggle) it keeps the stored value.

Custom ​

When no preset fits, describe your own — an explicit alphabet, a mask template, memorable words, or a value prefix:

php
GeneratorInput::make('code')->custom(alphabet: 'ABCDEF0123', length: 8);
GeneratorInput::make('ref')->custom(mask: 'INV-####-AA');   // # digit, A upper, a lower, * alnum, H hex
GeneratorInput::make('nickname')->words(2, separator: '-', appendDigits: 2);
GeneratorInput::make('key')->preset('nanoid')->valuePrefix('tok_');

The standalone <KinetixGenerator> can show a live preset picker with :presets="true" (or an array of names) — handy for a "generate anything" tool.

Standalone — generating into another target ​

The same generator ships as a component for use outside a form, pointed at any target. Pass :input="false" and a target — a writer callback, a CSS selector, or an element (its .value is set and an input event dispatched, so a plain <input> or another framework picks it up):

vue
<script setup lang="ts">
import KinetixGenerator from '@/components/kinetix/KinetixGenerator.vue';
</script>

<template>
  <!-- With its own input (v-model) -->
  <KinetixGenerator v-model:value="pwd" :config="{ kind: 'password', length: 16 }" copyable />

  <!-- Button only, writing into an external field -->
  <input id="api-key" />
  <KinetixGenerator
    :input="false"
    target="#api-key"
    :config="{ kind: 'password', length: 32 }"
  />
</template>

PhoneInput ​

An international phone field: a searchable country selector (flag + dial code, from a built-in list of all calling codes) plus a national-number input. Stores the full E.164-style string, e.g. +5215512345678.

International phone inputInternational phone input
php
use Happones\Kinetix\Forms\Components\PhoneInput;

PhoneInput::make('phone')->defaultCountry('MX');
PhoneInput::make('phone')->countries(['US', 'MX', 'CA']);   // restrict the list

The country list comes from Support\Countries + Support\DialCodes; the selected country's dial code is prefixed to the number on save.


JsonInput ​

A JSON editor: a monospace text area that says whether the JSON holds and, when it doesn't, the line and column where it breaks, with a Format button that indents it. The json rule is added.

php
use Happones\Kinetix\Forms\Components\JsonInput;

JsonInput::make('settings');            // a JSON string column
JsonInput::make('settings')->decoded(); // a column cast to array: saves the array

A value the model already decoded (an array cast) opens formatted.

TimezoneSelect ​

A searchable time zone picker: every IANA zone, labelled with its current UTC offset and ordered by it ((UTC−06:00) America/Mexico City), so typing a city or an offset finds it. The timezone rule is added.

php
use Happones\Kinetix\Forms\Components\TimezoneSelect;

TimezoneSelect::make('timezone')->default(config('app.timezone'));

7. Hidden ​

Tracks form values that must be submitted to the backend without displaying them to the user.

php
use Happones\Kinetix\Forms\Components\Hidden;

Hidden::make('referrer_id')
    ->default(fn () => request('ref'));

8. Radio ​

Radio groupRadio group

A single-choice radio group. Extends Select, so it shares the same options() API — including automatic PHP Enum reflection.

php
use Happones\Kinetix\Forms\Components\Radio;

Radio::make('plan')
    ->options(['free' => 'Free', 'pro' => 'Pro', 'team' => 'Team'])
    ->default('free');

// Enum-backed, laid out horizontally
Radio::make('status')
    ->options(PostStatus::class)
    ->inline();
MethodDescription
->options(array|Closure|string)Choices map, closure, or Enum class (inherited from Select)
->inline(bool = true)Lay options out horizontally instead of stacked

9. CheckboxList ​

A multi-choice list whose value is an array of the selected keys. Extends Select for option handling. Pair it with an array cast (or JSON column) on the model.

php
use Happones\Kinetix\Forms\Components\CheckboxList;

CheckboxList::make('permissions')
    ->options([
        'posts.create' => 'Create posts',
        'posts.delete' => 'Delete posts',
    ])
    ->inline();
MethodDescription
->options(array|Closure|string)Choices map, closure, or Enum class (inherited from Select)
->inline(bool = true)Lay checkboxes out horizontally instead of stacked
->bulkToggleable(bool = true)A Select all checkbox above the options, with a count (2/5). It checks every option in view, or clears them, and shows a dash when only some are checked. With ->searchable() it acts on the options the search left, and leaves the rest of the selection as it is
php
CheckboxList::make('channels')
    ->options(['mail' => 'Mail', 'sms' => 'SMS', 'push' => 'Push'])
    ->searchable()
    ->bulkToggleable();

10. ColorPicker ​

A native color swatch paired with a hex text input. The stored value is the hex string (e.g. #4f46e5).

php
use Happones\Kinetix\Forms\Components\ColorPicker;

ColorPicker::make('brand_color')
    ->default('#4f46e5');

11. TagsInput ​

Tags inputTags input

A free-text tag editor. The stored value is an array of strings. Type and press Enter or , to add a tag; Backspace on an empty input removes the last tag. Pair it with an array cast.

php
use Happones\Kinetix\Forms\Components\TagsInput;

TagsInput::make('skills')
    ->placeholder('Add a skill and press Enter');

Local typing state is encapsulated in the dedicated KinetixTagsInput.vue component, so the buffer never leaks across fields.

A tag already in the list isn't added again: the input says so and points at the existing tag. With distinct:ignore_case on the items ('skills.*' => 'distinct:ignore_case', or the field's own rules) a repeat in another case counts too, and a max: on the list stops the input once it's full.

12. KeyValue ​

Key-value fieldKey-value field

An editable map of key/value string pairs. The stored value is an associative array (object). Pair it with an array cast.

php
use Happones\Kinetix\Forms\Components\KeyValue;

KeyValue::make('metadata');

Fix the keys with ->keys(['title', 'color']) (Laravel's array:title,color rule, which it adds): the field shows one labelled input per key, with no rows to add or remove, and the server refuses any other key.

Row labels (Key, Value, Add row) come from the kinetix i18n namespace. Rendering and local row state live in KinetixKeyValue.vue, which resyncs from the parent only when the external value differs — avoiding an emit→prop feedback loop.

13. Repeater ​

Repeats a sub-schema over a list of items. The stored value is an array of objects, one per item. Pair it with an array cast (or a JSON column). Items can be added, removed, and reordered.

php
use Happones\Kinetix\Forms\Components\Repeater;
use Happones\Kinetix\Forms\Components\TextInput;
use Happones\Kinetix\Forms\Components\Select;

Repeater::make('line_items')
    ->schema([
        TextInput::make('description')->columnSpan(8)->required(),
        TextInput::make('quantity')->numeric()->columnSpan(2),
        Select::make('unit')->options(['ea' => 'each', 'kg' => 'kg'])->columnSpan(2),
    ])
    ->minItems(1)
    ->maxItems(20)
    ->addActionLabel('Add line item');
MethodDescription
->schema(array)The fields repeated for each item
->minItems(int)Minimum items (disables removing below it)
->maxItems(int)Maximum items (disables adding above it)
->addActionLabel(string)Custom "add" button label (defaults to kinetix.add_item)

Architecture note: KinetixFormSchema renders each item by recursing into itself with the item object as its scoped values, so any field type — including nested Grid/Section — works inside a repeater. New blank items are seeded from each sub-field's defaultValue. Validation treats the repeater as a single array-valued field (per-item rules are not auto-expanded in this version).

14. FileUpload ​

File upload dropzoneFile upload dropzone

Uploads files to a storage disk and stores the resulting path (a string, or an array of strings when multiple()). Uploads happen immediately via a dedicated endpoint; the field value is the stored path(s).

php
use Happones\Kinetix\Forms\Components\FileUpload;

FileUpload::make('avatar')
    ->image()
    ->disk('public')
    ->directory('avatars')
    ->maxSize(1024); // KB

FileUpload::make('attachments')
    ->multiple()
    ->maxFiles(5)
    ->acceptedFileTypes(['application/pdf', 'docx'])
    ->directory('docs');
MethodDescription
->disk(string)Storage disk; defaults to the global kinetix.filesystem.disk (public). Set to any disk (e.g. s3).
->directory(string)Target directory (default uploads)
->multiple(bool = true)Accept multiple files (value becomes an array)
->image()Restrict to images and render thumbnail previews
->acceptedFileTypes(array)MIME types (image/png) and/or extensions (pdf)
->maxSize(int)Max size per file in kilobytes
->maxFiles(int)Max number of files (multiple mode)

Checked before the upload. The field's rules (mimes, extensions, mimetypes, image, File::types()->max(), max: in kilobytes, dimensions) are checked in the browser before a file is sent: a 40 MB photo or a PDF in an image field is refused with a message naming the file and why, the others in the same pick still upload, and the picker only offers the accepted types. The server validates again.

Security: the storage config (disk, directory, constraints) is signed into an encrypted uploadToken sent to the client; the upload endpoint (uploads/store) decrypts it, re-validates the file against the constraints, and stores it — the client can never choose an arbitrary disk/directory. A matching uploads/delete endpoint removes a file, constrained to the configured directory. Rendering and upload state live in KinetixFileUpload.vue. Pre-existing values render their preview via the public disk URL convention (/storage/{path}) or any absolute URL.


5. Operations & Visibility Constraints ​

You can restrict field rendering based on the type of operation (e.g. 'create' or 'edit') or active database record properties.

Setting the Operation & Model ​

The operation is inferred automatically when you pass a model ('edit' if the record exists, otherwise 'create'). You can set both explicitly on the Form, which is what visibleOn / hiddenOn evaluate against:

  • Form::operation(string $operation): Force the active operation (e.g. ->operation('edit')).
  • Form::model(string $model): Set the target model class (FQCN) when no model instance is passed.
php
Form::make()
    ->model(User::class)
    ->operation('create')
    ->schema([ /* ... */ ]);

Operation Restrictions ​

  • hiddenOn(string|array $operations): Hides the field on specified operations:
    php
    TextInput::make('password')
        ->hiddenOn('edit'); // Password cannot be edited from this form
  • visibleOn(string|array $operations): Only displays the field during specified actions:
    php
    TextInput::make('reset_token')
        ->visibleOn('create');

Closure Evaluators ​

Provide dynamic rules evaluated on the server using closures.

  • hidden(Closure|bool $condition): Hide dynamically.
  • visible(Closure|bool $condition): Show dynamically.
php
TextInput::make('billing_vat_id')
    ->visible(fn (?Order $record) => $record && $record->requires_vat);

Authorization (Gate/Policy) ​

Fields are authorized on the server, using the same ->authorize() shorthand as Actions. A field that fails its check is dropped everywhere the form touches it: toArray()/toData() (never sent to the frontend), getValidationRules() (no rule generated), and getState() (never dehydrated) — so a user can't reveal or submit a field they aren't allowed to see.

php
// Laravel policy ability — checked against the record via Gate::allows($ability, $record):
TextInput::make('internal_notes')->authorize('editInternalNotes');

// Explicit subject (e.g. a create-time field with no record yet):
TextInput::make('role')->authorize('assignRole', Post::class);

// Any custom logic:
TextInput::make('salary')->authorize(fn (?Employee $record) => auth()->user()->isHr());
MethodBehaviour
->authorize(string $ability, mixed $subject = null)Gate::allows($ability, $subject ?? $record)
->authorize(Closure $cb)$cb($record) returns a boolean
->authorize(bool)Static gate

Without an explicit subject, a record-dependent ability can't be evaluated until a record exists — it defers to visible on create (no record) and is checked normally on edit, exactly like EditAction::make()->authorize('update').


6. Form Validation ​

Kinetix automatically generates standard Laravel validator arrays. This guarantees that your backend and frontend validation remain synchronized without duplicating validation logic.

1. Built-in Validation Rules ​

Chaining these methods on fields automatically populates the validation rules array:

MethodGenerated Laravel Rule
required()required
numeric()numeric
email()email
url()url
maxLength(int $length)max:{$length}
minLength(int $length)min:{$length}

Optional fields accept an empty value. A field that isn't required() but carries rules is validated as nullable too, so clearing an optional email(), url(), numeric() or minLength() field passes, while a filled value is still checked. (Laravel turns an emptied input into null, which those rules would otherwise reject.) Presence rules such as required_if still apply alongside it, and a field with no rules stays rule-free.

2. Conditional required ​

required() also accepts a closure, evaluated on the server against the current record. The field is only required when the closure returns truthy:

php
TextInput::make('vat_id')
    ->required(fn (?Order $record) => $record && $record->requires_vat);

3. Appending Custom Validation Rules ​

For advanced validation scenarios (such as conditional checking, database uniqueness, or custom rule objects), append them using rules():

php
use Illuminate\Validation\Rules\Password;

TextInput::make('password')
    ->required()
    ->rules([
        'string',
        Password::min(8)->mixedCase()->numbers(),
    ]);

4. Custom Messages & Attribute Names ​

Every rule the schema generates flows through Laravel's validator, so you can override its messages and the :attribute placeholder. By default each field contributes its label as the :attribute, so messages already read naturally ("The Email Address field is required."). Override per field, or at the form level:

php
TextInput::make('email')
    ->label('Email Address')
    ->required()
    ->email()
    ->validationMessages([
        'required' => 'We really need your email.',   // keyed by rule name
        'email'    => 'That doesn’t look like an email.',
    ])
    ->validationAttribute('work email');               // overrides the label in messages

// Form-level overrides win over field-level ones (keyed the standard dotted way):
Form::make()
    ->schema([...])
    ->messages(['email.required' => 'Email is mandatory.'])
    ->validationAttributes(['email' => 'E-mail']);

5. Controller Execution (fluent) ​

Run the validation directly using the $form->validate($request->all()) helper. It uses the same rules, messages, and attributes described above:

php
public function store(Request $request)
{
    $form = $this->getForm();

    // Runs validation, throwing a standard ValidationException on failure
    $validated = $form->validate($request->all());

    // Retrieve clean, dehydrated data
    $data = $form->getState($request->all());

    User::create($data);

    return redirect()->route('users.index');
}

6. FormRequest Bridge ​

When you prefer a dedicated FormRequest (for authorize(), prepareForValidation(), custom after hooks, etc.), don't re-declare the rules — pull them from the form so validation lives in one place. Extend KinetixFormRequest and implement form():

php
use Happones\Kinetix\Forms\Form;
use Happones\Kinetix\Forms\Http\KinetixFormRequest;

class StorePostRequest extends KinetixFormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    protected function form(): Form
    {
        return PostForm::make()->model(Post::class);
    }

    // Optional — layered on top of the schema's rules/messages/attributes:
    protected function additionalRules(): array
    {
        return ['captcha' => ['required']];
    }
}

The request's rules(), messages(), and attributes() are derived from the form automatically. In the controller, dehydratedState() returns the validated + dehydrated data (runs dehydrateStateUsing() hooks and drops saved(false) fields):

php
public function store(StorePostRequest $request)
{
    Post::create($request->dehydratedState());

    return redirect()->route('posts.index');
}

Already extending another base request? Use the trait instead — same API:

php
use Happones\Kinetix\Forms\Http\ResolvesKinetixForm;

class StorePostRequest extends ApiFormRequest
{
    use ResolvesKinetixForm;

    protected function form(): Form { return PostForm::make(); }
}

7. Live Validation (Precognition) ​

Kinetix ships a built-in Laravel Precognition client (no extra npm dependency) that validates fields against the server as the user edits them, reusing your FormRequest rules. Two steps:

1. Add the middleware to the route and use a Kinetix FormRequest (so Precognition has rules to run):

php
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

Route::post('/posts', [PostController::class, 'store'])
    ->middleware(HandlePrecognitiveRequests::class);

2. Opt the form in. The client validates against the submit URL as fields change:

php
PostForm::make()
    ->precognitive()                        // reuse the submit URL (set on the client)
    // or point it explicitly:
    ->validationUrl(route('posts.store'), 'post');

In a table's modal, a RouteFormAction with ->precognitive() validates against the route it submits to (Actions → Submitting to your own route).

On the Vue side, pass the endpoint (when the form didn't hard-code one) via validationUrl:

vue
<KinetixForm :form="postForm" validation-url="/posts" @submit="submit" />

Keep JSON errors for JSON requests

Live validation reads the 422 Laravel returns to a request that expects JSON. If your bootstrap/app.php narrows shouldRenderJsonWhen (say, to api/*), keep $request->expectsJson() in it — otherwise the validation error comes back as a redirect and no live error ever shows:

php
$exceptions->shouldRenderJsonWhen(
    fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);

As the user edits a field it's debounced-validated server-side; a 422 surfaces the message inline (live errors win over the last submit's), a pass clears it. KinetixForm also hides a stale submit error for a field the moment the user starts editing it, and keeps it hidden when the form is resubmitted — only the next response's error bag can flag the field again.

8. Error Focus in Tabs & Wizards ​

Server (and live) validation errors are rendered and revealed automatically, even when the offending field is inside a collapsed layout:

  • KinetixForm reads Inertia's errors bag directly — a ValidationException from your controller now shows in the UI with no wiring.
  • Tabs mark any tab whose fields carry an error (a destructive dot + aria-invalid) and switch to the first offending tab on submit — so an error is never hidden behind an inactive tab.
  • Wizards mark errored steps on the indicator (destructive), keep them navigable even under linear gating, and jump to the first errored step on submit.
  • After the right panel is revealed, the first errored field is focused and scrolled into view (in declaration/DOM order), retrying across frames until its panel mounts.

This is fully recursive — a wizard inside a tab inside a section resolves correctly, because each container independently reveals the child holding an error. Live (per-field) validation never yanks focus off the field being edited, since its error is already in the active panel.

Submitted values survive a failed validation round-trip. When a submit comes back with errors, the controller reran and re-serialized the form from the ORIGINAL record (edit) or the blank blueprint (create) — KinetixForm detects the error bag and keeps the user's typed values instead of syncing from the incoming props, so a cleared required field stays cleared and a half-filled create form keeps its input. The next error-free render (a successful save) syncs normally.


7. Complete Integration Guide ​

Here is a full integration walkthrough showcasing a production-ready user profile update setup.

1. The Controller (App\Http\Controllers\ProfileController.php) ​

php
namespace App\Http\Controllers;

use App\Models\User;
use App\Enums\UserRole;
use Happones\Kinetix\Forms\Form;
use Happones\Kinetix\Forms\Components\Grid;
use Happones\Kinetix\Forms\Components\Section;
use Happones\Kinetix\Forms\Components\TextInput;
use Happones\Kinetix\Forms\Components\Select;
use Happones\Kinetix\Forms\Components\Toggle;
use Happones\Kinetix\Forms\Components\Textarea;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

class ProfileController extends Controller
{
    /**
     * Define the form schema blueprint
     */
    protected function getProfileForm(User $user): Form
    {
        return Form::make($user)
            ->schema([
                Section::make('Public Profile')
                    ->description('This information will be displayed publicly.')
                    ->columns(12)
                    ->schema([
                        TextInput::make('name')
                            ->required()
                            ->maxLength(100)
                            ->columnSpan(6),

                        TextInput::make('email')
                            ->email()
                            ->required()
                            ->rules([
                                Rule::unique('users', 'email')->ignore($user->id),
                            ])
                            ->columnSpan(6),

                        Textarea::make('bio')
                            ->placeholder('Tell us about yourself...')
                            ->rows(3)
                            ->columnSpan('full'),
                    ]),

                Section::make('System Administration')
                    ->description('Internal system parameters.')
                    ->columns(12)
                    ->schema([
                        Select::make('role')
                            ->options(UserRole::class)
                            ->required()
                            ->columnSpan(6),

                        Toggle::make('is_active')
                            ->label('Active Status')
                            ->default(true)
                            ->columnSpan(6),
                    ]),
            ]);
    }

    /**
     * Show the edit view
     */
    public function edit(Request $request)
    {
        $user = $request->user();
        
        // Prepare the form and hydrate data from model
        $form = $this->getProfileForm($user)->fill($user);

        return inertia('Settings/Profile', [
            // Convert to Spatie DTO array for Inertia delivery
            'profileForm' => $form->toArray(),
        ]);
    }

    /**
     * Process the updates
     */
    public function update(Request $request)
    {
        $user = $request->user();
        $form = $this->getProfileForm($user);

        // 1. Validate inputs
        $form->validate($request->all());

        // 2. Obtain validated, dehydrated data
        $data = $form->getState($request->all());

        // 3. Update the Model
        $user->update($data);

        return redirect()->back()->with('message', 'Profile updated successfully.');
    }
}

2. The Vue 3 Component (resources/js/pages/Settings/Profile.vue) ​

vue
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import KinetixForm from '@/components/kinetix/KinetixForm.vue';

const props = defineProps<{
    profileForm: any;
}>();

const handleFormSubmit = (formValues: Record<string, any>) => {
    // Send submission payload to the update route using Inertia
    router.put('/settings/profile', formValues, {
        preserveScroll: true,
        onSuccess: () => {
            // Success feedback trigger
        },
    });
};
</script>

<template>
    <div class="max-w-4xl mx-auto py-10 px-4 sm:px-6 lg:px-8">
        <div class="space-y-6">
            <div>
                <h1 class="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white">
                    Account Settings
                </h1>
                <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
                    Manage your personal profile and account preferences.
                </p>
            </div>

            <!-- Kinetix Form Component -->
            <KinetixForm 
                :form="profileForm" 
                @submit="handleFormSubmit"
            >
                <!-- Custom Action buttons (replaces default submit button) -->
                <template #default="{ values }">
                    <div class="flex items-center gap-3">
                        <button
                            type="submit"
                            class="px-4 py-2 text-sm font-semibold rounded-lg shadow bg-indigo-600 hover:bg-indigo-500 text-white transition-colors"
                        >
                            Save Settings
                        </button>
                        
                        <button
                            type="button"
                            @click="router.get('/dashboard')"
                            class="px-4 py-2 text-sm font-semibold rounded-lg border border-neutral-300 dark:border-neutral-700 bg-transparent text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
                        >
                            Cancel
                        </button>
                    </div>
                </template>
            </KinetixForm>
        </div>
    </div>
</template>

Released under the MIT License.