DEV Community

Cover image for Dev.to — Alpine.js Patterns for Dynamic Laravel Forms
Sumeet Shroff
Sumeet Shroff

Posted on

Dev.to — Alpine.js Patterns for Dynamic Laravel Forms

Alpine.js Patterns for Dynamic Laravel Forms

If you're already using Laravel with Blade templates and want form interactions without reaching for a full JavaScript framework, Alpine.js is the right tool. At roughly 15 KB gzipped, it handles conditional fields, multi-step flows, dynamic repeater rows, and inline validation feedback — all declared directly in your HTML attributes.

This article focuses specifically on form patterns: the practical, real-world cases where Alpine.js saves you from writing dozens of lines of jQuery or wiring up a full Vue component for something that's really just three state variables. If you want the broader picture of where Alpine fits in the Laravel frontend ecosystem alongside Livewire 4, Inertia, React, and Vue, see Modern Laravel Frontends: Livewire 4, Inertia, React, Vue, and Alpine.js.

Prerequisites and Versions

  • Laravel 11 or 12
  • Alpine.js v3.15.x (latest stable as of June 2026 is v3.15.11, released April 1, 2026)
  • Blade templates (no Livewire required for any pattern in this article)
  • Basic familiarity with x-data, x-show, and x-model

Install Alpine via CDN (fast prototyping) or npm (production):

<!-- CDN -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js"></script>
Enter fullscreen mode Exit fullscreen mode
# npm
npm install alpinejs@^3.15
Enter fullscreen mode Exit fullscreen mode
// resources/js/app.js
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Enter fullscreen mode Exit fullscreen mode

Pattern 1 — Conditional Fields

The most common Alpine form pattern: show or hide fields based on the value of another field. A service enquiry form that reveals extra fields when "Custom Package" is selected:

<form method="POST" action="/enquiry" x-data="{ serviceType: '' }">
    @csrf

    <select name="service_type" x-model="serviceType">
        <option value="">Select a service</option>
        <option value="web-design">Website Design</option>
        <option value="seo">SEO</option>
        <option value="custom">Custom Package</option>
    </select>

    <div x-show="serviceType === 'custom'" x-transition style="display:none">
        <label>Describe your requirements</label>
        <textarea name="custom_requirements" rows="4"></textarea>

        <label>Estimated budget (INR)</label>
        <input type="number" name="budget" placeholder="e.g. 50000" />
    </div>

    <button type="submit">Send Enquiry</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The style="display:none" on the conditional div ensures it's hidden on page load before Alpine initialises — preventing a flash of unwanted content. x-transition adds a smooth opacity-and-scale entrance.

On the Laravel side, your validation rule conditionally requires the textarea only when service_type is custom:

// app/Http/Controllers/EnquiryController.php
public function store(Request $request): RedirectResponse
{
    $request->validate([
        'service_type'        => ['required', 'string'],
        'custom_requirements' => ['required_if:service_type,custom', 'string', 'max:1000'],
        'budget'              => ['required_if:service_type,custom', 'integer', 'min:1000'],
    ]);

    // store enquiry...

    return back()->with('success', 'Enquiry submitted.');
}
Enter fullscreen mode Exit fullscreen mode

Keep validation on the server. Alpine's conditional display is a UX convenience, not a security gate.


Pattern 2 — Dynamic Repeater Rows

Allowing users to add multiple entries — team members, line items, phone numbers — without page reloads. This pattern uses Alpine's x-for with a reactive array.

<div x-data="{
    contacts: [{ name: '', phone: '' }],
    addRow()  { this.contacts.push({ name: '', phone: '' }); },
    removeRow(index) { this.contacts.splice(index, 1); }
}">

    <template x-for="(contact, index) in contacts" :key="index">
        <div class="flex gap-3 mb-3">
            <input
                type="text"
                :name="'contacts[' + index + '][name]'"
                x-model="contact.name"
                placeholder="Full name"
            />
            <input
                type="tel"
                :name="'contacts[' + index + '][phone]'"
                x-model="contact.phone"
                placeholder="Phone number"
            />
            <button type="button" @click="removeRow(index)"
                    x-show="contacts.length > 1">
                Remove
            </button>
        </div>
    </template>

    <button type="button" @click="addRow">Add Contact</button>
</div>
Enter fullscreen mode Exit fullscreen mode

The :name binding uses the array index to produce contacts[0][name], contacts[1][name], etc. Laravel's request()->input('contacts') returns exactly this as a nested array.

Validate nested input in your controller:

$request->validate([
    'contacts'         => ['required', 'array', 'min:1'],
    'contacts.*.name'  => ['required', 'string', 'max:100'],
    'contacts.*.phone' => ['required', 'regex:/^[6-9]\d{9}$/'],
]);
Enter fullscreen mode Exit fullscreen mode

Note on x-for and Alpine v3.15: Alpine now supports Set objects inside x-for, and template-based x-sort handles work correctly — but for basic repeater patterns, arrays remain the simplest choice.


Pattern 3 — Multi-Step Form with Progress

Breaking a long form into steps keeps users on task. Alpine manages the current step index; all form fields live inside a single <form> so a single POST submits everything.

<form method="POST" action="/project-brief"
      x-data="{ step: 1, totalSteps: 3 }">
    @csrf

    {{-- Step indicator --}}
    <p>Step <span x-text="step"></span> of <span x-text="totalSteps"></span></p>
    <div class="h-2 bg-gray-200 rounded">
        <div class="h-2 bg-blue-600 rounded"
             :style="'width:' + (step / totalSteps * 100) + '%'"></div>
    </div>

    {{-- Step 1: Contact details --}}
    <div x-show="step === 1" style="display:none">
        <input type="text"  name="name"  placeholder="Your name" />
        <input type="email" name="email" placeholder="Email address" />
    </div>

    {{-- Step 2: Project type --}}
    <div x-show="step === 2" style="display:none">
        <select name="project_type">
            <option value="ecommerce">Ecommerce Website</option>
            <option value="corporate">Corporate Website</option>
            <option value="landing">Landing Page</option>
        </select>
    </div>

    {{-- Step 3: Budget and deadline --}}
    <div x-show="step === 3" style="display:none">
        <input type="number" name="budget"   placeholder="Budget in INR" />
        <input type="date"   name="deadline" />
    </div>

    {{-- Navigation --}}
    <button type="button" @click="step--" x-show="step > 1">Back</button>
    <button type="button" @click="step++" x-show="step < totalSteps">Next</button>
    <button type="submit"                 x-show="step === totalSteps">Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

This approach has a deliberate limitation: client-side step validation is not enforced. A user can click submit on step 3 with step 1 fields empty — Laravel's server-side validation catches this and returns errors. If you need per-step client validation, add a validateStep() method to your x-data that checks required fields before advancing. Keep that lightweight — complex validation logic is a signal to consider Livewire instead.


Pattern 4 — Character Counter and Inline Feedback

Real-time character counts and field-level feedback with zero server round-trips:

<div x-data="{
    message: '',
    maxChars: 500,
    get remaining() { return this.maxChars - this.message.length; },
    get isOverLimit() { return this.message.length > this.maxChars; }
}">
    <textarea
        name="message"
        x-model="message"
        :class="{ 'border-red-500': isOverLimit }"
        rows="5"
        placeholder="Tell us about your project..."
    ></textarea>

    <p :class="isOverLimit ? 'text-red-600' : 'text-gray-500'">
        <span x-text="remaining"></span> characters remaining
    </p>
</div>
Enter fullscreen mode Exit fullscreen mode

Getter properties (get remaining(), get isOverLimit()) in Alpine x-data objects work cleanly and re-evaluate reactively whenever message changes. This avoids storing derived state as separate reactive properties.


Pattern 5 — AJAX Submission Without Page Reload

Alpine can post a form via fetch and display success or error messages inline. This is appropriate for single-action widgets like a newsletter signup or a quick quote request — not for complex multi-field forms where error mapping across many fields gets unwieldy.

<div x-data="{
    email: '',
    status: null,
    errorMsg: '',
    async submit() {
        this.status = 'loading';
        const res = await fetch('/newsletter/subscribe', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
                'Accept': 'application/json',
            },
            body: JSON.stringify({ email: this.email }),
        });
        if (res.ok) {
            this.status = 'success';
        } else {
            const data = await res.json();
            this.errorMsg = data.message ?? 'Something went wrong.';
            this.status = 'error';
        }
    }
}">
    <input type="email" x-model="email" placeholder="Enter your email" />
    <button type="button" @click="submit" :disabled="status === 'loading'">
        <span x-show="status !== 'loading'">Subscribe</span>
        <span x-show="status === 'loading'">Subscribing...</span>
    </button>
    <p x-show="status === 'success'" class="text-green-600" style="display:none">You're subscribed!</p>
    <p x-show="status === 'error'"   class="text-red-600"   style="display:none" x-text="errorMsg"></p>
</div>
Enter fullscreen mode Exit fullscreen mode

The CSRF token is read from the meta tag — always include <meta name="csrf-token" content="{{ csrf_token() }}"> in your layout. The Laravel controller returns response()->json() with appropriate status codes.


Common Mistakes

Forgetting style="display:none" on x-show elements. Without it, elements flash visible before Alpine boots, especially on slower devices. Always add the inline style to any element using x-show.

Putting business logic in Alpine expressions. Alpine evaluates expressions as JavaScript. Injecting any user-controlled string into an Alpine expression (x-bind, x-on, x-data) creates an XSS vector. Never dynamically construct Alpine directives from server-rendered user data.

Skipping server-side validation because Alpine handles it client-side. Alpine validation is strictly a UX enhancement. Every form submission must be validated in Laravel. Period.

Using Alpine for forms with 10+ interdependent fields. When your x-data object grows beyond 6–8 properties with multiple watchers and computed properties, maintainability drops. That's the signal to move to a Livewire component where PHP handles the logic and you get a clean class structure.

Not accounting for Alpine's CSP constraint. Alpine evaluates expressions using new Function(), which requires unsafe-eval in your Content Security Policy. If you need a strict CSP, use the Alpine CSP build (alpinejs/dist/cdn-csp.min.js). Be aware that enabling Livewire 4's csp_safe mode in config/livewire.php also forces the entire app to use Alpine's CSP evaluator, which restricts complex expressions in directives app-wide.


Testing Alpine Forms

Alpine state is entirely client-side, so testing happens at two levels:

Laravel feature tests — submit the form via HTTP and assert validation behaviour:

// tests/Feature/EnquiryFormTest.php
public function test_custom_package_requires_requirements(): void
{
    $response = $this->post('/enquiry', [
        'service_type' => 'custom',
        // missing custom_requirements
    ]);

    $response->assertSessionHasErrors('custom_requirements');
}
Enter fullscreen mode Exit fullscreen mode

Browser tests — use Laravel Dusk to verify Alpine behaviour in a real browser:

// tests/Browser/EnquiryFormTest.php
public function test_custom_fields_appear_on_selection(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/enquiry')
                ->assertMissing('textarea[name=custom_requirements]')
                ->select('service_type', 'custom')
                ->waitFor('textarea[name=custom_requirements]')
                ->assertVisible('textarea[name=custom_requirements]');
    });
}
Enter fullscreen mode Exit fullscreen mode

Dusk runs a real Chrome instance, so x-show, x-transition, and x-model behaviour is tested accurately.


When Alpine Is Enough vs When It Isn't

Alpine is the right choice when your form interaction is primarily about showing/hiding, counting, or posting to a single endpoint. It is the wrong choice when you need server-side reactivity (e.g., fetching a dynamic price from the database as the user changes fields, or running a real-time search). Those scenarios belong in Livewire, where every user interaction triggers a PHP method and Laravel handles the logic.

The practical line: if solving the problem requires a fetch call that returns structured data (not just a success/error), Alpine starts to fight against you and a Livewire component is cleaner.


If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.

Top comments (0)