Building a DRY Form System with Inertia.js

· By Chrysostomos Zampetakis · Category : Laravel · 7 min read

Every Laravel developer knows the tedium of writing yet another form. In a typical CRUD application, form-related code can easily account for 30-40% of your frontend codebase. While Inertia.js elegantly bridges Laravel and frontend frameworks like Vue, it doesn’t inherently solve the problem of form repetition.

By the end of this guide, you’ll have a system where new forms require just 10-15 lines of code, both for creation and editing operations. Here’s a quick before and after:

Before optimization:

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head } from "@inertiajs/vue3";

import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";

const form = useForm({
    name: "",
    email: "",
    phone: "",
    address: "",
    city: "",
    state: "",
    postal_code: "",
    notes: "",
});

const processing = ref(false);
const errors = ref({});

function submit() {
    processing.value = true;

    form.post("/customers", {
        onSuccess: () => {
            // Handle success
            processing.value = false;
        },
        onError: (errors) => {
            // Handle validation errors
            processing.value = false;
        },
    });
}
</script>

<template>
    <Head title="Create Customer" />
    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
                Create Customer
            </h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
                <div class="md:grid md:grid-cols-3 md:gap-6">
                    <div class="md:col-span-1">
                        <div class="px-4 sm:px-0">
                            <h3 class="text-lg font-medium text-gray-900">
                                Customer Information
                            </h3>
                            <p class="mt-1 text-sm text-gray-600">
                                Enter the customer's basic information.
                            </p>
                        </div>
                    </div>
                    <div class="mt-5 md:mt-0 md:col-span-2">
                        <form @submit.prevent="submit">
                            <div class="shadow overflow-hidden sm:rounded-md">
                                <div class="px-4 py-5 bg-white sm:p-6">
                                    <div class="grid grid-cols-6 gap-6">
                                        <div class="col-span-6 sm:col-span-4">
                                            <label
                                                for="name"
                                                class="block text-sm font-medium text-gray-700"
                                                >Name</label
                                            >
                                            <input
                                                type="text"
                                                name="name"
                                                id="name"
                                                v-model="form.name"
                                                class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
                                            />
                                            <div
                                                v-if="errors.name"
                                                class="text-red-500 text-sm mt-1"
                                            >
                                                {{ errors.name[0] }}
                                            </div>
                                        </div>

                                        <div class="col-span-6 sm:col-span-4">
                                            <label
                                                for="email"
                                                class="block text-sm font-medium text-gray-700"
                                                >Email</label
                                            >
                                            <input
                                                type="email"
                                                name="email"
                                                id="email"
                                                v-model="form.email"
                                                class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
                                            />
                                            <div
                                                v-if="errors.email"
                                                class="text-red-500 text-sm mt-1"
                                            >
                                                {{ errors.email[0] }}
                                            </div>
                                        </div>

                                        <!-- Repeat similar blocks for other fields -->
                                    </div>
                                </div>
                                <div
                                    class="px-4 py-3 bg-gray-50 text-right sm:px-6"
                                >
                                    <button
                                        type="submit"
                                        :disabled="processing"
                                        class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                                    >
                                        <span v-if="processing"
                                            >Processing...</span
                                        >
                                        <span v-else>Save</span>
                                    </button>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

After optimization:

<!-- pages/Customers/Create.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";

const form = useCustomForm({
    endpoint: "/customers",
    initialValues: {
        name: "",
        email: "",
        phone: "",
        address: "",
        city: "",
        state: "",
        postal_code: "",
        notes: "",
    },
    redirectOnSuccess: "/customers",
});
</script>

<template>
    <FormPage
        title="Create Customer"
        description="Enter the customer's basic information."
    >
        <TextInput
            v-model="form.data.name"
            label="Name"
            :error="form.errors.name"
            required
        />
        <EmailInput
            v-model="form.data.email"
            label="Email"
            :error="form.errors.email"
            required
        />
        <TextInput
            v-model="form.data.phone"
            label="Phone"
            :error="form.errors.phone"
        />
        <TextInput
            v-model="form.data.address"
            label="Address"
            :error="form.errors.address"
        />
        <TextInput
            v-model="form.data.city"
            label="City"
            :error="form.errors.city"
        />
        <TextInput
            v-model="form.data.state"
            label="State"
            :error="form.errors.state"
        />
        <TextInput
            v-model="form.data.postal_code"
            label="Postal Code"
            :error="form.errors.postal_code"
        />
        <TextareaInput
            v-model="form.data.notes"
            label="Notes"
            :error="form.errors.notes"
        />

        <template #form-actions>
            <button
                type="button"
                :disabled="form.processing.value"
                @click="form.submit()"
                class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
                <span v-if="form.processing.value">Processing...</span>
                <span v-else>Save Customer</span>
            </button>
        </template>
    </FormPage>
</template>

Setting Up the Foundation

Our tech stack:

  • Laravel 10+
  • Inertia.js 1.0+
  • Vue 3 (Composition API)
  • Tailwind CSS

Our form system has three main layers:

  1. Base Input Components: Reusable UI elements for different input types
  2. Form Structure Components: Layout components for organizing forms consistently
  3. Form Submission Logic: A composable that handles the submission flow and state management

Creating Reusable Form Components

Base Input Components

First, let’s create a BaseInput.vue component:

<!-- components/Form/BaseInput.vue -->
<script setup>
import { computed } from "vue";

const props = defineProps({
    id: {
        type: String,
        default: () => `input-${Math.random().toString(36).substring(2, 9)}`,
    },
    modelValue: { type: [String, Number, Boolean, Array, Object], default: "" },
    label: { type: String, default: "" },
    error: { type: [String, Array, Object], default: null },
    helpText: { type: String, default: "" },
    required: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
});

const emit = defineEmits(["update:modelValue"]);

const hasError = computed(() => !!props.error);

const inputClasses = computed(() => {
    return [
        "mt-1 block w-full rounded-md shadow-sm sm:text-sm",
        hasError.value
            ? "border-red-300 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500"
            : "border-gray-300 focus:border-indigo-500 focus:ring-indigo-500",
    ];
});

const updateValue = (value) => {
    emit("update:modelValue", value);
};
</script>

<template>
    <div class="mb-4">
        <label
            v-if="label"
            :for="id"
            class="block text-sm font-medium text-gray-700"
        >
            {{ label }} <span v-if="required" class="text-red-500">*</span>
        </label>

        <slot
            :id="id"
            :update-value="updateValue"
            :input-classes="inputClasses"
        ></slot>

        <p v-if="error" class="mt-1 text-sm text-red-600">
            {{ Array.isArray(error) ? error[0] : error }}
        </p>

        <p v-else-if="helpText" class="mt-1 text-sm text-gray-500">
            {{ helpText }}
        </p>
    </div>
</template>

Now, let’s create a text input component:

<!-- components/Form/TextInput.vue -->
<script setup>
import BaseInput from "./BaseInput.vue";

const props = defineProps({
    modelValue: { type: String, default: "" },
    type: { type: String, default: "text" },
    label: { type: String, default: "" },
    placeholder: { type: String, default: "" },
    error: { type: [String, Array, Object], default: null },
    helpText: { type: String, default: "" },
    required: { type: Boolean, default: false },
    disabled: { type: Boolean, default: false },
});

const emit = defineEmits(["update:modelValue"]);
</script>

<template>
    <BaseInput
        :model-value="modelValue"
        :label="label"
        :error="error"
        :help-text="helpText"
        :required="required"
        :disabled="disabled"
        @update:model-value="emit('update:modelValue', $event)"
    >
        <template #default="{ id, updateValue, inputClasses }">
            <input
                :id="id"
                :type="type"
                :value="modelValue"
                :placeholder="placeholder"
                :disabled="disabled"
                :required="required"
                :class="inputClasses"
                @input="updateValue($event.target.value)"
            />
        </template>
    </BaseInput>
</template>

Form Structure Components

Let’s create components for structuring our forms:

<!-- components/Form/FormSection.vue -->
<script setup>
defineProps({
    title: { type: String, default: "" },
    description: { type: String, default: "" },
});
</script>

<template>
    <div class="md:grid md:grid-cols-3 md:gap-6 mb-8">
        <div class="md:col-span-1">
            <div class="px-4 sm:px-0">
                <h3 v-if="title" class="text-lg font-medium text-gray-900">
                    {{ title }}
                </h3>
                <p v-if="description" class="mt-1 text-sm text-gray-600">
                    {{ description }}
                </p>
            </div>
        </div>

        <div class="mt-5 md:mt-0 md:col-span-2">
            <div class="shadow overflow-hidden sm:rounded-md">
                <div class="px-4 py-5 bg-white sm:p-6">
                    <div>
                        <slot></slot>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

For form actions:

<!-- components/Form/FormActions.vue -->
<script setup>
// No props needed
</script>

<template>
    <div class="px-4 py-3 bg-gray-50 text-right sm:px-6">
        <slot></slot>
    </div>
</template>

And a FormPage.vue component for the base layout:

<!-- components/Form/FormPage.vue -->
<script setup>
import FormSection from "@/Components/Form/FormSection.vue";
import FormActions from "@/Components/Form/FormActions.vue";

defineProps({
    title: { type: String, default: "" },
    description: { type: String, default: "" },
});
</script>

<template>
    <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
        <form @submit.prevent>
            <FormSection :title="title" :description="description">
                <slot></slot>
            </FormSection>

            <slot name="actions">
                <FormActions>
                    <slot name="form-actions"></slot>
                </FormActions>
            </slot>
        </form>
    </div>
</template>

Building a Form Submission Composable

The heart of our system is a powerful form composable that handles all the form logic:

// composables/useCustomForm.js
import { ref, reactive, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { cloneDeep } from 'lodash'

export function useCustomForm(options) {
  const {
    endpoint,
    method = 'POST',
    initialValues = {},
    resetOnSuccess = true,
    redirectOnSuccess = null,
    onSuccess = null,
    onError = null
  } = options
  
  // Create reactive data
  const original = cloneDeep(initialValues)
  const data = reactive(cloneDeep(initialValues))
  const errors = ref({})
  const processing = ref(false)
  const successful = ref(false)
  const recentlySuccessful = ref(false)
  
  // Determine if form is for create or edit
  const isEditForm = computed(() => {
    return method.toUpperCase() === 'PUT' || method.toUpperCase() === 'PATCH'
  })
  
  // Check if any fields are dirty (modified)
  const isDirty = computed(() => {
    return JSON.stringify(data) !== JSON.stringify(original)
  })
  
  // Reset form to original values
  const reset = () => {
    Object.assign(data, cloneDeep(original))
    errors.value = {}
    successful.value = false
    recentlySuccessful.value = false
  }
  
  // Process the form submission
  const submit = () => {
    processing.value = true
    
    // Use the appropriate Inertia method based on the form method
    const routeMethod = method.toLowerCase()
    
    // Handle file uploads
    const hasFiles = hasFilesDeep(data)
    const formData = hasFiles ? objectToFormData(data) : data
    
    const options = {
      onStart: () => {
        processing.value = true
        errors.value = {}
        successful.value = false
      },
      onSuccess: (page) => {
        successful.value = true
        recentlySuccessful.value = true
        
        if (resetOnSuccess) {
          reset()
        }
        
        if (redirectOnSuccess) {
          router.visit(redirectOnSuccess)
        }
        
        if (onSuccess) {
          onSuccess(page)
        }
        
        // Reset "recentlySuccessful" after 2 seconds
        setTimeout(() => {
          recentlySuccessful.value = false
        }, 2000)
      },
      onError: (validationErrors) => {
        errors.value = validationErrors
        
        if (onError) {
          onError(validationErrors)
        }
      },
      onFinish: () => {
        processing.value = false
      }
    }
    
    // Use special options for file uploads
    const finalOptions = hasFiles ? {
      ...options,
      forceFormData: true,
      preserveScroll: true,
    } : options
    
    router[routeMethod](endpoint, formData, finalOptions)
  }
  
  // Helper to set individual field values
  const setField = (field, value) => {
    data[field] = value
    
    // Clear the error for this field when it's changed
    if (errors.value[field]) {
      delete errors.value[field]
    }
  }
  
  // Set all data at once (useful for edit forms)
  const setData = (newData) => {
    Object.assign(data, newData)
    Object.assign(original, cloneDeep(newData))
    errors.value = {}
  }
  
  // Helper to check if object contains File instances
  const hasFilesDeep = (obj) => {
    if (!obj) return false
    
    if (obj instanceof File) return true
    
    if (Array.isArray(obj)) {
      return obj.some(item => hasFilesDeep(item))
    }
    
    if (typeof obj === 'object') {
      return Object.values(obj).some(val => hasFilesDeep(val))
    }
    
    return false
  }
  
  // Convert object to FormData for file uploads
  const objectToFormData = (obj, formData = new FormData(), parentKey = '') => {
    if (obj === null || obj === undefined) return formData
    
    if (obj instanceof File) {
      formData.append(parentKey, obj)
      return formData
    }
    
    if (Array.isArray(obj)) {
      obj.forEach((item, index) => {
        objectToFormData(item, formData, `${parentKey}[${index}]`)
      })
      return formData
    }
    
    if (typeof obj === 'object' && !(obj instanceof File) && !(obj instanceof Date)) {
      Object.keys(obj).forEach(key => {
        const propName = parentKey ? `${parentKey}[${key}]` : key
        objectToFormData(obj[key], formData, propName)
      })
      return formData
    }
    
    // Handle Date objects
    if (obj instanceof Date) {
      formData.append(parentKey, obj.toISOString())
      return formData
    }
    
    // Handle primitive values
    formData.append(parentKey, obj)
    return formData
  }
  
  // Expose everything we need
  return {
    data,
    errors,
    processing,
    successful,
    recentlySuccessful,
    isDirty,
    isEditForm,
    reset,
    submit,
    setField,
    setData
  }
}

Implementing Create and Edit Pages

Create Page

<!-- pages/Customers/Create.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";

const form = useCustomForm({
    endpoint: "/customers",
    initialValues: {
        name: "",
        email: "",
        phone: "",
        address: "",
        city: "",
        state: "",
        postal_code: "",
        notes: "",
    },
    redirectOnSuccess: "/customers",
});
</script>

<template>
    <FormPage
        title="Create Customer"
        description="Enter the customer's basic information."
    >
        <TextInput
            v-model="form.data.name"
            label="Name"
            :error="form.errors.name"
            required
        />
        <EmailInput
            v-model="form.data.email"
            label="Email"
            :error="form.errors.email"
            required
        />
        <TextInput
            v-model="form.data.phone"
            label="Phone"
            :error="form.errors.phone"
        />
        <TextInput
            v-model="form.data.address"
            label="Address"
            :error="form.errors.address"
        />
        <TextInput
            v-model="form.data.city"
            label="City"
            :error="form.errors.city"
        />
        <TextInput
            v-model="form.data.state"
            label="State"
            :error="form.errors.state"
        />
        <TextInput
            v-model="form.data.postal_code"
            label="Postal Code"
            :error="form.errors.postal_code"
        />
        <TextareaInput
            v-model="form.data.notes"
            label="Notes"
            :error="form.errors.notes"
        />

        <template #form-actions>
            <button
                type="button"
                :disabled="form.processing.value"
                @click="form.submit()"
                class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
                <span v-if="form.processing.value">Processing...</span>
                <span v-else>Save Customer</span>
            </button>
        </template>
    </FormPage>
</template>

Edit Page

<!-- pages/Customers/Edit.vue -->
<script setup>
import { useCustomForm } from "@/Composables/useCustomForm";
import FormPage from "@/Components/Form/FormPage.vue";
import TextInput from "@/Components/Form/TextInput.vue";
import EmailInput from "@/Components/Form/EmailInput.vue";
import TextareaInput from "@/Components/Form/TextareaInput.vue";

const props = defineProps({
    customer: {
        type: Object,
        required: true,
    },
});

const form = useCustomForm({
    endpoint: `/customers/${props.customer.id}`,
    method: "PUT",
    initialValues: props.customer,
    redirectOnSuccess: "/customers",
});
</script>

<template>
    <FormPage
        title="Edit Customer"
        description="Update the customer's information."
    >
        <TextInput
            v-model="form.data.name"
            label="Name"
            :error="form.errors.name"
            required
        />
        <EmailInput
            v-model="form.data.email"
            label="Email"
            :error="form.errors.email"
            required
        />
        <TextInput
            v-model="form.data.phone"
            label="Phone"
            :error="form.errors.phone"
        />
        <TextInput
            v-model="form.data.address"
            label="Address"
            :error="form.errors.address"
        />
        <TextInput
            v-model="form.data.city"
            label="City"
            :error="form.errors.city"
        />
        <TextInput
            v-model="form.data.state"
            label="State"
            :error="form.errors.state"
        />
        <TextInput
            v-model="form.data.postal_code"
            label="Postal Code"
            :error="form.errors.postal_code"
        />
        <TextareaInput
            v-model="form.data.notes"
            label="Notes"
            :error="form.errors.notes"
        />

        <template #form-actions>
            <button
                type="button"
                :disabled="form.processing.value"
                @click="form.submit()"
                class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
                <span v-if="form.processing.value">Processing...</span>
                <span v-else>Update Customer</span>
            </button>
        </template>
    </FormPage>
</template>

Conclusion

We’ve built a streamlined form system for Inertia.js applications that dramatically reduces code duplication. The benefits are clear:

  • Reduced code duplication: Eliminated repetitive form boilerplate
  • Consistent user experience: All forms have the same look and behavior
  • Faster development: Creating new forms requires minimal code
  • Improved maintainability: Changes can be made in one place

By investing time in creating this system, you’ll save countless hours of development time and create a more consistent, maintainable codebase.