Skip to main content
Every data mutation goes through an action. CoreActionForm connects your form to that action and handles the full lifecycle — validation errors mapped to fields, loading state, confirmation dialogs, and redirects. There are three ways to render an action’s form:
  1. Schema auto-form — generated from the action’s schema(), zero frontend code
  2. Custom form — your own React component, wired to the action
  3. Embedded card — either of the above, rendered inline on a page
Reserve raw CoreForm submissions for non-mutation cases like login forms or third-party integrations. All data mutations should go through actions.

Schema Auto-Form

If your action defines a schema(), the platform auto-generates a form with the correct inputs, labels, and validation. This is the default when no custom form() is defined.
app/Actions/Order/CreateOrderAction.php
class CreateOrderAction extends CoreAction
{
    protected function schema(): SchemaDefinition
    {
        return SchemaDefinition::make([
            ObjectProperty::make('customer', __('Customer'))
                ->model(Customer::class)
                ->searchEndpoint(route('customers.search'))
                ->required(),

            DateProperty::make('ordered_at', __('Order date'))
                ->required(),

            StringProperty::make('notes', __('Notes'))
                ->nullable(),
        ]);
    }

    public function authorize(): bool
    {
        return has_permission(Permission::CREATE_ORDERS);
    }

    protected function defaults(): void
    {
        $this->label(__('Create order'))
            ->icon('Plus')
            ->variant(CoreVariant::BRAND);
    }

    protected function handle(): ActionResult
    {
        $order = Order::create([
            'customer_id' => $this->integer('customer'),
            'ordered_at'  => $this->date('ordered_at'),
            'notes'       => $this->get('notes'),
        ]);

        return $this->success(__('Order created.'))->object($order);
    }
}
When a CoreActionButton for this action is clicked with missing parameters, a modal opens with auto-generated inputs for customer, ordered_at, and notes. No frontend form code needed.

Pre-Filled and Hidden Parameters

When parameters are pre-filled via params() or frontend params/getParams, matching schema fields are hidden automatically. The field is still submitted to the action, but it is not shown in the form. When using $subjectClass, the subject is handled entirely separately and never appears as a schema field.

Custom Form Component

For complex forms that need custom layout, conditional fields, or specialized inputs, provide a React component via form():
app/Actions/Order/CreateOrderAction.php
protected function form(): ?ActionForm
{
    return $this->component('orders/create-order-form', [
        'warehouses' => Warehouse::active()->pluck('name', 'id'),
    ]);
}
form() is a data method — it returns a component path and serializable props, but never renders React itself. The platform calls form() during action serialization and includes the result in CoreActionData. The React component is resolved client-side via dynamic import() and receives the props — no separate AJAX request.
In table contexts, form() is not called per row — that would be expensive. The form component and props are only serialized when the action is rendered as a standalone button or card on a page. When a user clicks a table row action, the form is loaded on-demand via the modal route.
resources/js/pages/orders/create-order-form.tsx
import {
  CoreActionForm,
  CoreFormSelect,
  CoreFormSubmitButton,
} from '@/core/components/core-form';
import { PropertyCoreFormInput, useActionSchema } from '@/core/properties';

export default function CreateOrderForm({ action, warehouses }) {
  const schema = useActionSchema(action);

  return (
    <CoreActionForm
      action={action}
      footer={
        <CoreFormSubmitButton variant="brand">
          {__('Create order')}
        </CoreFormSubmitButton>
      }
    >
      {/* Schema-driven — type, label, required, and search config derived automatically */}
      <PropertyCoreFormInput data={schema.customer} />
      <PropertyCoreFormInput data={schema.ordered_at} />
      <PropertyCoreFormInput data={schema.notes} />

      {/* Custom field — warehouse is not in the schema, use a plain CoreForm input */}
      <CoreFormSelect
        name="warehouse"
        label={__('Warehouse')}
        options={Object.entries(warehouses).map(([id, name]) => ({ value: id, label: name }))}
      />
    </CoreActionForm>
  );
}
The custom component is a normal React component in the same render tree as the rest of the page — hooks, context, and the Inertia router all work as expected. When form() is not defined, the schema auto-form is used instead.

Bulk Context

When a custom form component is used by a bulk action, CoreActionForm receives a bulkContext prop. Pass it through to CoreActionForm and the form submission is automatically routed to the bulk endpoint — no separate bulk form needed.
resources/js/pages/orders/set-order-status-form.tsx
interface Props {
  action: Inly.Core.Data.CoreActionData;
  bulkContext?: CoreActionFormBulkContext;  // present when opened from a bulk selection
  onSuccess?: () => void;
}

export default function SetOrderStatusForm({ action, bulkContext, onSuccess }) {
  const schema = useActionSchema(action);

  return (
    <CoreActionForm
      action={action}
      bulkContext={bulkContext}
      onSuccess={onSuccess}
      footer={
        <CoreActionSubmitButton action={action}>
          {__('Apply')}
        </CoreActionSubmitButton>
      }
    >
      <PropertyCoreFormInput data={schema.status} />
    </CoreActionForm>
  );
}
When bulkContext is present, CoreActionForm posts to /actions/{action}/bulk with the selected IDs and your form values merged. The bulkModelParam (the ObjectProperty that identifies the per-item model) is excluded from the payload — the server injects it per item. When bulkContext is absent (single-item modal or page form), submission proceeds normally against /actions/{action}. The CoreActionFormBulkContext type is exported from core-action-form:
import { CoreActionFormBulkContext } from '@/core/components/core-form/core-action-form';
The same component handles both single-item and bulk contexts. Design your form for the single-item case and simply forward bulkContext — no conditional branches needed.

Accessing the Action’s Schema

useActionSchema(action) converts the serialized action.schemaProperties array into a keyed record — so schema.customer returns the PropertyMeta for the customer field. This data is already included in CoreActionData whenever the action defines a schema(), no extra props needed.
const schema = useActionSchema(action);
// schema.customer  → PropertyMeta { type: 'object', label: 'Customer', required: true, relatedClass: '...', ... }
// schema.ordered_at → PropertyMeta { type: 'date', label: 'Order date', required: true, ... }

Controlled Form State with useCoreActionForm

When you need programmatic access to form values, use useCoreActionForm(action, { ... }). It replaces the common useForm() + CoreActionForm pairing by:
  • seeding form state from action params, schema values, and schema defaults
  • keeping extra bound params separate from editable form values
  • reusing the same submit pipeline as CoreActionForm
If the form is rendered via CoreActionFormComponent from a parent page (to share state with a map or sibling component), pass the returned form to CoreActionFormComponent and it will be forwarded to your custom component. See CoreActionFormComponent below.
import { useCoreActionForm } from '@/core/hooks/use-core-action-form';

export default function CreateTestForm({ action, programOptions }) {
  const schema = useActionSchema(action);

  const form = useCoreActionForm(action, {
    values: {
      programs: [],
    },
  });

  const toggleProgram = (value: string) => {
    const next = form.data.programs.includes(value)
      ? form.data.programs.filter(v => v !== value)
      : [...form.data.programs, value];
    form.setData('programs', next);
  };

  return (
    <CoreActionForm action={action} form={form} footer={...}>
      <PropertyCoreFormInput data={schema.name} />
      <PropertyCoreFormInput data={schema.required_question_count} />

      {/* Custom UI that reads/writes form state */}
      {programOptions.map(opt => (
        <Checkbox
          key={opt.value}
          checked={form.data.programs.includes(opt.value)}
          onCheckedChange={() => toggleProgram(opt.value)}
        />
      ))}
    </CoreActionForm>
  );
}
Use values for editable initial state and params for extra bound values that should stay hidden:
const form = useCoreActionForm(action, {
  params: { school: school.id },
  values: { notes: draftNotes ?? '' },
});
Without a form prop, CoreActionForm creates and manages this hook internally. Reach for useCoreActionForm only when the surrounding UI needs direct access to form.data or form.setData. When you want full TypeScript autocomplete, type the action prop with the generated action params. If your form has extra frontend-only fields, extend that shape explicitly:
import { ActionWithSchema } from '@/core/properties';
import { useCoreActionForm } from '@/core/hooks/use-core-action-form';

type CreateTestAction = ActionWithSchema<
  App.Actions.CertificationTest.CreateCertificationTestActionParams
>;

type CreateTestFormData =
  App.Actions.CertificationTest.CreateCertificationTestActionParams & {
    programs: string[];
  };

export default function CreateTestForm({
  action,
}: {
  action: CreateTestAction;
}) {
  const form = useCoreActionForm<CreateTestAction, CreateTestFormData>(action, {
    values: { programs: [] },
  });

  form.setData('name', 'My test');
  form.setData('programs', ['1', '2']);

  return null;
}
If the action prop is only typed as plain CoreActionData, the hook falls back to a generic record because TypeScript cannot infer the schema from the runtime lookupKey.

Embedded Forms

Embed an action’s form directly on a page — no modal, no button click. There are two components for this:
  • CoreActionCard — renders the form inside a CoreCard with title and description. Use for standalone create/edit pages.
  • CoreActionFormComponent — renders just the form, no card wrapper. Use when you need to embed the form in a custom layout, or when you need controlled access to the form state from the parent component.

CoreActionCard

resources/js/pages/orders/order-create.tsx
import { CoreActionCard } from '@/core/components/core-action-card';

export default function OrderCreate({ createAction }) {
  return (
    <CoreActionCard action={createAction} />
  );
}
CoreActionCard renders the action’s form (custom or auto-generated) inside a CoreCard. It is the standard way to build simple create and edit pages.

CoreActionFormComponent

Use CoreActionFormComponent when you need to embed the form in your own layout, or when the parent component needs to read or write form state — for example, to drive a map preview, live calculation, or sibling UI that responds to field changes.
resources/js/pages/orders/order-show.tsx
import { CoreActionFormComponent } from '@/core/components/core-action-form-component';
import { CoreCard } from '@/core/components/core-card';
import { useCoreActionForm } from '@/core/hooks/use-core-action-form';

export default function OrderShow({ order, updateAction }) {
  const form = useCoreActionForm(updateAction);

  return (
    <CoreShowPage
      sidebar={<ShippingEstimate weight={form.data.weight} />}
    >
      <CoreCard title={__('Order details')}>
        <CoreActionFormComponent action={updateAction} form={form} />
      </CoreCard>
    </CoreShowPage>
  );
}
The form instance is forwarded to the custom form component declared via form() on the action. Your custom form component receives it as a form prop — and uses it as the controlled CoreActionForm state. If no form is passed, the custom form component manages its own state internally.
CoreActionFormComponent also works without a custom form() — it falls back to the schema auto-form when no custom form component is declared.

How It Works

There is no AJAX fetch to load the form. Everything needed is already in the serialized CoreActionData:
  1. Your page controller serializes the action as a normal Inertia prop
  2. During serialization, the platform calls form() and includes the component path and props in CoreActionData
  3. On the frontend, CoreActionCard / CoreActionFormComponent checks if a custom form component is declared:
    • Custom form: resolves the React component via import(), renders it with the serialized props
    • No custom form: renders CoreActionSchemaForm from the schema properties
  4. The result is a normal React component in the page’s render tree — no iframes, no portals
Since form() runs during serialization, your custom form’s server-side data (dropdown options, related models, computed values) are available on the initial page load — just like any other Inertia prop.

CoreActionFormComponent

CoreActionFormComponent renders the action’s form without a card wrapper — either the custom component declared via form(), or the schema auto-form as a fallback.

Props

PropTypeDescription
actionCoreActionDataThe action whose form to render
form?Inertia formOptional — from useCoreActionForm() or useForm() for controlled state; forwarded to the custom form component
schema?PropertyMeta[]Override schema properties (defaults to action.schemaProperties)
onSuccess?() => voidCalled on successful execution
onError?(error: any) => voidCalled on non-validation errors
submitLabel?stringSubmit button label for schema auto-forms
bulkContext?CoreActionFormBulkContextWhen provided, submits as a bulk action; forwarded to the custom component and schema auto-form

CoreActionForm

CoreActionForm is the form component for all action mutations. Both the auto-form and custom forms use it internally. You use it directly in custom form components.

Props

PropTypeDescription
actionCoreActionDataThe action to execute on submit
form?Inertia formOptional — from useCoreActionForm() or useForm() for controlled state
params?Record<string, any>Static parameters merged with form data
getParams?() => Record<string, any>Dynamic parameters computed at submit time
onSuccess?() => voidCalled on successful execution
onError?(error: any) => voidCalled on non-validation errors
onValidationError?(errors: any) => voidCalled on validation errors
bulkContext?CoreActionFormBulkContextWhen provided, submits as a bulk action with the given IDs and filters
footer?ReactNodeSubmit button area
disabled?booleanDisable all form fields
className?stringAdditional CSS classes
All CoreForm props are also accepted.

Parameter Priority

Parameters merge in this order (highest wins):
  1. getParams() — dynamic, computed at submit time
  2. params prop — static
  3. Form field data — from form fields
  4. Backend defaults — from action.params()

Passing Context Parameters

Use getParams for values that aren’t form fields:
<CoreActionForm
  action={reassignAction}
  getParams={() => ({
    order: order.id,
  })}
>
  <CoreFormSearchCombobox name="user" label={__('Assign to')} ... />
</CoreActionForm>

CoreActionSchemaForm

The auto-generated form component. Renders one PropertyCoreFormInput per schema property. You rarely use this directly — it’s what the auto-modal renders — but it’s available for custom layouts:
import { CoreActionSchemaForm } from '@/core/components/core-action-schema-form';

<CoreActionSchemaForm
  action={action}
  schema={action.schemaProperties ?? []}
  onSuccess={() => router.reload()}
/>
Properties with pre-bound values are automatically excluded from the form.

Schema-Driven Fields in Custom Forms

Use PropertyCoreFormInput for fields that exist in the action’s schema, and plain CoreForm inputs for everything else. The schema carries the type, label, required flag, options, and search config — PropertyCoreFormInput resolves the correct input component automatically.

From the Action’s Own Schema

import { PropertyCoreFormInput, useActionSchema } from '@/core/properties';

export default function CreateOrderForm({ action, warehouses }) {
  const schema = useActionSchema(action);

  return (
    <CoreActionForm action={action} footer={...}>
      <PropertyCoreFormInput data={schema.customer} />
      <PropertyCoreFormInput data={schema.ordered_at} />
      <CoreFormSelect name="warehouse" label={__('Warehouse')} options={...} />
    </CoreActionForm>
  );
}

From a Domain Object’s Schema

When editing an existing record, pass property data from the object’s schema instead:
<CoreActionForm action={updateAction} footer={...}>
  <PropertyCoreFormInput data={order.schema.status} />
  <PropertyCoreFormInput data={order.schema.ordered_at} />
  <CoreFormInput name="internal_note" label={__('Internal note')} />
</CoreActionForm>

Overriding Defaults with inputProps

Pass inputProps to forward props to the underlying CoreForm component. These merge with — or override — the property-derived configuration:
{/* Override the label */}
<PropertyCoreFormInput data={schema.notes} label={__('Order notes')} />

{/* Pass excludedIds to a search combobox (ObjectProperty) */}
<PropertyCoreFormInput
  data={schema.program_id}
  inputProps={{
    searchComboboxProps: { excludedIds: existingProgramIds },
    placeholder: __('Search programs...'),
  }}
/>

{/* Override enum options with a filtered subset */}
<PropertyCoreFormInput
  data={schema.type}
  inputProps={{ options: allowedTypes }}
/>

{/* Add prefix/suffix to a string or number input */}
<PropertyCoreFormInput
  data={schema.weight}
  inputProps={{ suffix: 'kg' }}
/>
inputProps are spread onto the resolved input component, so they support the same props as the underlying CoreForm field (CoreFormInput, CoreFormSearchCombobox, CoreFormEnumSelect, etc.).

Validation

Server-Side Validation

Validation errors from the action’s schema() (or rules()) are automatically mapped to matching form fields. No manual wiring needed.
// Backend
DateProperty::make('ordered_at', __('Order date'))->required();
// Frontend — error appears below the field automatically
<CoreFormInput name="ordered_at" label={__('Order date')} type="date" />

Precognition (Live Validation)

Action forms automatically use Laravel Precognition for real-time field-by-field validation. When the user blurs a field, a lightweight validation request runs against the action’s rules without executing handle(). This works out of the box — no configuration per action.

Confirmation Dialogs

Actions with confirm() show a confirmation dialog before submitting. Pass the message directly:
protected function defaults(): void
{
    $this->label(__('Delete order'))
        ->variant(CoreVariant::DESTRUCTIVE)
        ->confirm(__('This will permanently delete the order and all its lines.'));
}
The dialog appears regardless of whether the form is auto-generated, custom, or embedded.

Preconditions in Forms

When an action has preconditions, the form’s submit button should reflect them. Use CoreActionSubmitButton instead of CoreFormSubmitButton — it accepts the action and renders a precondition checklist popover on hover when conditions are unmet.
import { CoreActionSubmitButton } from '@/core/components/core-action-submit-button';

<CoreActionForm action={approveAction}>
  <CoreFormInput name="notes" label={__('Approval notes')} />

  <CoreForm.Footer>
    <CoreActionSubmitButton action={approveAction}>
      {__('Approve')}
    </CoreActionSubmitButton>
  </CoreForm.Footer>
</CoreActionForm>
When all preconditions pass, CoreActionSubmitButton behaves exactly like CoreFormSubmitButton. When any condition is unmet, the button is disabled and hovering shows the checklist — green checks for met conditions, red X marks for unmet ones.
Schema auto-forms and auto-modals use CoreActionSubmitButton automatically. You only need to wire it manually in custom CoreActionForm layouts.

Edit / Update Forms

Edit forms are a common use case. The platform supports pre-filling form fields with existing values so the user can review and modify them.

Model-Based Auto-Fill

The recommended approach is to declare $subjectClass on the action and derive the schema from the subject model. When a subject is bound, the platform automatically extracts its attribute values and uses them as property defaults — no frontend code and no manual ->default() calls:
class UpdateOrderAction extends CoreAction
{
    protected static ?string $subjectClass = Order::class;

    protected function schema(): SchemaDefinition
    {
        return Order::schemaDefinition()
            ->only(['status', 'notes', 'priority']);
    }

    protected function handle(): ActionResult
    {
        $this->subject()->update([
            'status'   => $this->enum('status', OrderStatus::class),
            'notes'    => $this->string('notes')->value(),
            'priority' => $this->integer('priority'),
        ]);

        return $this->success(__('Order updated'));
    }
}
// Controller
UpdateOrderAction::make($order)
The form opens with status, notes, and priority pre-filled from the order. See Subject-based actions for details.

Manual Defaults

When you need defaults from a source other than the subject model’s attributes, set ->default() on individual properties. Since schema() has access to the subject, use $this->subject() to read current values:
protected function schema(): SchemaDefinition
{
    $order = $this->subject();

    return SchemaDefinition::make([
        StringProperty::make('notes', __('Notes'))
            ->default($order->notes),

        EnumProperty::make('priority', __('Priority'))
            ->enum(Priority::class)
            ->default($order->priority),
    ]);
}
Without $subjectClass, you can also type-hint the model in schema() directly (old style):
protected function schema(Order $order): SchemaDefinition
{
    return SchemaDefinition::make([
        ObjectProperty::make('order')
            ->model(Order::class)
            ->required(),

        StringProperty::make('notes', __('Notes'))
            ->default($order->notes),
    ]);
}

Custom Edit Forms

For custom form components, initialize useCoreActionForm with any extra editable values you need beyond the action’s schema-derived state:
export default function EditOrderForm({ action, order }) {
  const schema = useActionSchema(action);

  const form = useCoreActionForm(action, {
    values: {
      notes: order.notes ?? '',
      priority: order.priority ?? '',
    },
  });

  return (
    <CoreActionForm action={action} form={form} footer={...}>
      <PropertyCoreFormInput data={schema.notes} />
      <PropertyCoreFormInput data={schema.priority} />
    </CoreActionForm>
  );
}
Schema auto-forms handle this automatically — property.default is used to initialize each field.

Embedded Custom Edit Forms (page-owned form state)

When an edit form is embedded on a show page alongside UI that must react to field changes (maps, previews, calculated values), lift the useCoreActionForm instance up to the page and pass it to CoreActionFormComponent. The form component receives it as a form prop — use it instead of creating an internal one:
resources/js/pages/cities/city-show.tsx
import { CoreActionFormComponent } from '@/core/components/core-action-form-component';

export default function CityShow({ city, updateCityAction }) {
  const form = useCoreActionForm(updateCityAction, {
    values: {
      name: city.schema.name?.value ?? '',
      map_position_x: city.schema.map_position_x?.value ?? '',
      map_position_y: city.schema.map_position_y?.value ?? '',
    },
  });

  return (
    <CoreShowPage
      sidebar={
        <CityMap
          x={form.data.map_position_x}
          y={form.data.map_position_y}
          onPositionChange={(x, y) => form.setData({ ...form.data, map_position_x: x, map_position_y: y })}
        />
      }
    >
      <CoreCard title={__('Information')}>
        <CoreActionFormComponent action={updateCityAction} form={form} />
      </CoreCard>
    </CoreShowPage>
  );
}
resources/js/pages/cities/city-edit-form.tsx
interface Props {
  action: Inly.Core.Data.CoreActionData;
  form?: InertiaFormProps<CityFormData>;  // injected by CoreActionFormComponent when controlled
  canEdit?: boolean;
  onSuccess?: () => void;
}

export default function CityEditForm({ action, form: externalForm, canEdit = true, onSuccess }) {
  const schema = useActionSchema(action);
  const internalForm = useCoreActionForm(action, { values: { name: schema.name?.default ?? '', ... } });
  const form = externalForm ?? internalForm;  // use controlled form when provided

  return (
    <CoreActionForm action={action} form={form} disabled={!canEdit} onSuccess={onSuccess} footer={...}>
      <PropertyCoreFormInput data={schema.name} />
      <PropertyCoreFormInput data={schema.map_position_x} />
      <PropertyCoreFormInput data={schema.map_position_y} />
    </CoreActionForm>
  );
}
The server-side action passes canEdit (and any other per-request data) via formProps:
protected function form(): ActionForm
{
    return $this->component('cities/city-edit-form', [
        'canEdit' => user()?->can(Permission::MANAGE_CITIES->value) ?? false,
    ]);
}

Best Practices

  1. Use CoreActionForm for all mutations. Create, update, and delete forms always go through an action.
  2. Define schema() whenever possible. Let the platform generate the form. Only provide form() when layout or UX demands it.
  3. Mix and match in custom forms. Use useActionSchema + PropertyCoreFormInput for schema-backed fields and plain CoreForm inputs for custom data. This keeps labels, types, and validation in sync with the backend while retaining full layout control.
  4. Pass context IDs via getParams. The current record’s ID belongs in getParams, not as a hidden form field.
  5. Put submit buttons in footer. Consistent layout across all forms.
  6. Keep actions focused. CreateOrderAction and UpdateOrderAction are separate classes.
  7. Declare $subjectClass for instance actions. Bind with make($model), access the model via $this->subject(), and read form fields via $this->get() / $this->string() / $this->integer() etc. — no typed params needed in handle().
  8. Use model-based schemas for edit forms. SubjectClass::schemaDefinition()->only([...]) gives automatic pre-fill with zero ->default() calls.

Next Steps

Form Components

Full reference for CoreFormInput, Select, Combobox, MultiSelect, Textarea, and more.

Property Rendering

Use PropertyCoreFormInput to derive labels, types, and validation from your object schema.