ComfyCoders TranquilTools
Docs /Laravel Vue FormBuilder
Repository
v1

Vue Usage

Basic usage

Import Form.vue from the @form-builder alias and pass the schema as a prop:

<script setup lang="ts">
import Form from '@form-builder/components/Form.vue';
import type { FormSchema } from '@form-builder/types/form-builder';

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

<template>
    <Form :schema="form" />
</template>

The component uses Inertia's useForm() internally and handles submission, error display, and loading state automatically.

Events

@success

Fired after a successful form submission (2xx response from Inertia):

<template>
    <Form :schema="form" @success="handleSuccess" />
</template>

<script setup lang="ts">
function handleSuccess() {
    // redirect, show toast, etc.
}
</script>

@error

Fired when submission returns validation errors:

<template>
    <Form :schema="form" @error="handleError" />
</template>

<script setup lang="ts">
function handleError(errors: Record<string, string>) {
    console.log(errors);
}
</script>

Reacting to field changes

Use @onFieldChange to run logic whenever any field value changes. This is useful for cross-field reactivity — for example, showing extra fields when a toggle is enabled, or fetching related data.

<template>
    <Form
        :schema="form"
        @onFieldChange="handleFieldChange"
    />
</template>

<script setup lang="ts">
function handleFieldChange(
    field: string,
    value: unknown,
    form: Record<string, unknown>
) {
    if (field === 'country' && value === 'US') {
        // do something
    }
}
</script>

The callback receives:

  • field — the name of the field that changed
  • value — the new value
  • form — the entire current form state as a plain object

Custom layouts with FormRenderer

Form.vue wraps everything in a <form> element. If you need a custom layout — for example, a two-column grid — use FormRenderer.vue instead. It renders as a fragment (no wrapper element) so it can be placed inside any grid or flex container.

<script setup lang="ts">
import FormRenderer from '@form-builder/components/FormRenderer.vue';
import type { FormSchema } from '@form-builder/types/form-builder';

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

<template>
    <form @submit.prevent="submit">
        <div class="grid grid-cols-2 gap-4">
            <FormRenderer :fields="form.fields" />
        </div>
        <button type="submit">Save</button>
    </form>
</template>

FormRenderer accepts:

  • fields — the array of field schemas from the form config
  • form (optional) — the current form state object (for conditional field visibility)
  • onFieldChange (optional) — same callback as on Form.vue
  • columns (optional) — 1 (default) to 4, see below

Two columns

Pass columns="2" to lay the fields out in a responsive two-column grid (3 and 4 work too, see Columns). Below the md breakpoint the form stays a single column.

<template>
    <Form :schema="form" :columns="2" />
</template>

Fields that need the full width get it automatically: wysiwyg, textarea, repeater, keyvalue, hidden and the button types. Any other field can ask for the same with fullWidth in its schema:

<template>
    <Form
        :schema="form"
        :columns="2"
        :field-overrides="{ notes: { fullWidth: true } }"
    />
</template>

With the default columns="1" the renderer still emits no wrapper element at all, so existing markup and CSS are untouched.

Horizontal labels

layout="horizontal" puts the label to the left of its control instead of above it, from the sm breakpoint up. Fields without a label stay stacked.

<template>
    <Form :schema="form" layout="horizontal" />
</template>

Reusing one schema for many rows

A dialog per table row ("reject booking", "put on option") does not need a schema per row. Render the form once in PHP and override the target and the values per use:

<template>
    <Dialog v-model:open="open">
        <Form
            :schema="rejectForm"
            :action="`/bookings/${booking.id}/reject`"
            method="patch"
            :defaults="{ reason: booking.reason ?? '' }"
            @success="open = false"
        />
    </Dialog>
</template>
  • action and method replace the schema's own for this form only.
  • defaults are merged over the schema defaults. When they change (another row opens the same dialog), the form loads them, resets the rest to the schema defaults and clears its errors. Values typed by the user stay as long as the defaults stay the same.
  • fieldOverrides keeps working for per-row props on a field, such as options.

Resetting after a successful submit

A form that adds lines ("add booking line", "add guest") should be empty again after each submit. Inertia keeps the page state after a POST, so the typed values would otherwise stay. Turn it on in PHP or per use:

$form->resetOnSuccess();
<Form :schema="lineForm" reset-on-success />

After a successful submit the form goes back to its defaults and clears its errors. A failed submit keeps what was typed. The prop wins over the schema, so :reset-on-success="false" keeps the values for one use.

Independent of this option, file fields always forget the chosen file after a successful submit (and their remove checkbox unticks): the upload is done, and submitting again should not upload it twice.

Errors that belong to no field

An error whose key matches no field — conflicts, form, a failed HTTP request — has nowhere to go under a field, so it is shown at the top of the form in a role="alert" block:

throw ValidationException::withMessages([
    'conflicts' => 'This booking overlaps with another one.',
]);

Keys of a field's nested values (lines.0.amount for a repeater lines, title.nl for a translatable title) and of remove flags count as that field's own. Replace the block with the form-errors slot:

<Form :schema="form">
    <template #form-errors="{ errors }">
        <Alert variant="destructive">
            <AlertDescription v-for="(message, key) in errors" :key="key">{{ message }}</AlertDescription>
        </Alert>
    </template>
</Form>

The same formErrors (key to first message) is exposed on the component ref.

Translations

Every text the renderer shows itself (date picker buttons, placeholders, "No results", aria labels, …) comes from a message catalogue. English is the default and Dutch is built in. Labels the schema carries, such as ->placeholder() or the translated labels PHP sends for selects and numbers, still win.

The locale is the first of:

  1. the locale prop on <Form>
  2. the locale set with configureFormMessages({ locale })
  3. the locale in the schema, which the PHP core fills with app()->getLocale() (override with $form->locale('nl'))
  4. en

A regional locale such as nl-BE falls back to nl; a missing message falls back to English.

// app.ts
import { configureFormMessages } from '@form-builder';

configureFormMessages({
    locale: 'nl',
    messages: {
        de: { pickDate: 'Datum wählen', today: 'Heute' },
        nl: { pickDate: 'Kies een dag' },
    },
});

Override single messages for one form with the messages prop:

<Form :schema="form" :messages="{ pickDate: 'Arrival day' }" />

The standalone components (TreeView, SortableList, Tabs) follow the global locale. The full list of keys is the FormMessages type; englishMessages and dutchMessages are exported as starting points for a new language.

Submitting without Inertia

By default the form submits through Inertia's useForm(). Pass transport="http" to submit over fetch instead and stay on the page — useful in a modal, a side panel or an admin screen that keeps its own state.

<template>
    <Form :schema="form" transport="http" @success="close" />
</template>

The built-in submitter:

  • sends the XSRF-TOKEN cookie as an X-XSRF-TOKEN header, falling back to a <meta name="csrf-token"> tag, so Laravel's VerifyCsrfToken does not answer 419
  • sends X-Requested-With: XMLHttpRequest and Accept: application/json, so validation failures come back as 422 JSON instead of a redirect
  • switches to multipart/form-data as soon as a field holds a file, and spoofs _method for PUT, PATCH and DELETE (the Inertia transport does the same: a PUT with a file goes out as a POST with _method)
  • spreads nested arrays and objects into filters[status][0]=open style parameters on a GET

Validation errors land on the matching field. Any other failure lands under the key form, which no field renders — read it yourself if you want to show it:

<template>
    <p v-if="formRef?.form.errors.form">{{ formRef.form.errors.form }}</p>
    <Form ref="formRef" :schema="form" transport="http" />
</template>

Your own transport

Pass a submitter to route the request through your own client, and an errorAdapter when your API does not answer in Laravel's { errors: { field: [...] } } shape.

<script setup lang="ts">
import type { HttpFormRequest } from '@form-builder';

async function submitter({ method, url, data }: HttpFormRequest) {
    return api.request({ method, url, data });
}

function errorAdapter(error: any) {
    return { name: error.response.data.detail };
}
</script>

<template>
    <Form
        :schema="form"
        transport="http"
        :submitter="submitter"
        :error-adapter="errorAdapter"
    />
</template>

The form exposes its own API under the names data, errors, processing, recentlySuccessful, reset, clearErrors, submit, get, post, put, patch and delete. A field with one of those names throws on setup rather than silently overwriting the method.