The problem: A Laravel Blade form needs client-side interactivity — conditional fields, live character counts, multi-step navigation — but pulling in a full JS framework feels like overkill, and plain vanilla JS turns into unmaintainable spaghetti spread across multiple <script> tags.
Environment: Laravel 11, Alpine.js 3.x (Livewire-bundled or standalone via npm), Blade forms, Tailwind CSS, Vite 5.
The Error
// Not an error — this is a "how do I structure this cleanly" problem.
// Common failure mode without Alpine: validation errors from
// Laravel wipe out any client-side toggled state on redirect-back,
// since old() values repopulate fields but not JS-only UI state
// (which step you were on, which conditional section was open).
Why It Happens
The core tension in a Laravel form is that Laravel's validation model is inherently a full-page round trip (or a Livewire round trip) — on failure, it redirects back with $errors and old() input. If a chunk of your form's UI state (which accordion section is open, which step of a multi-step form is active) lives only in memory as untracked JS variables, that state is gone the moment the page reloads on a validation failure, even though the field values themselves survive via old().
Alpine solves this cleanly because you can seed its initial state directly from Blade/PHP on page load, so client-side UI state and server-side validation state agree with each other instead of fighting.
The Fix
Here's a multi-step registration form where the active step is Alpine state, but seeded from PHP so a validation failure re-opens the step that actually has the error:
<div
x-data="{
step: {{ $errors->has('company_name') ? 2 : 1 }},
goTo(step) { this.step = step }
}"
>
<form method="POST" action="{{ route('register.store') }}">
@csrf
<!-- Step 1: account details -->
<div x-show="step === 1">
<label>Email</label>
<input type="email" name="email" value="{{ old('email') }}">
@error('email') <p class="text-red-600">{{ $message }}</p> @enderror
<button type="button" @click="goTo(2)">Next</button>
</div>
<!-- Step 2: company details -->
<div x-show="step === 2">
<label>Company Name</label>
<input type="text" name="company_name" value="{{ old('company_name') }}">
@error('company_name') <p class="text-red-600">{{ $message }}</p> @enderror
<button type="button" @click="goTo(1)">Back</button>
<button type="submit">Create Account</button>
</div>
</form>
</div>
A live character counter tied to a Laravel max: validation rule, so the client-side limit actually matches the server-side one instead of drifting apart over time:
<div x-data="{ bio: '{{ old('bio') }}', max: 200 }">
<textarea
name="bio"
x-model="bio"
:maxlength="max"
></textarea>
<p class="text-sm" :class="bio.length > max - 20 ? 'text-red-600' : 'text-gray-500'">
<span x-text="bio.length"></span>/<span x-text="max"></span>
</p>
</div>
// In the FormRequest handling this field, keep the limit in one place:
public function rules(): array
{
return [
'bio' => ['nullable', 'string', 'max:200'],
];
}
A conditionally required field (e.g. "other" reason box) that only shows and only gets submitted when relevant:
<div x-data="{ reason: '{{ old('reason', 'existing_customer') }}' }">
<select name="reason" x-model="reason">
<option value="existing_customer">Existing customer</option>
<option value="other">Other</option>
</select>
<div x-show="reason === 'other'">
<input type="text" name="reason_other" value="{{ old('reason_other') }}">
</div>
</div>
Step-by-Step
- Identify which parts of the form are purely visual state (open step, expanded section) versus actual data that needs to reach the server (form field values).
- Seed Alpine's initial state for visual/UI concerns directly from Blade using
$errorsorold(), so a validation failure reopens the right step or section automatically. - Keep any client-side limit (character counts, conditional-required fields) in sync with the actual
FormRequestvalidation rule — don't hardcode a different number in JS. - For conditionally shown fields, make sure your
FormRequestusesrequired_ifor similar so the server enforces the same condition the UI shows. - Test the full failure path manually: submit invalid data, confirm the page reopens to the correct step/section with the right field highlighted.
- Rebuild with
npm run buildafter any change.
x-show for step navigation renders all steps into the DOM at once (just hidden via CSS), which means every field across all steps gets submitted on the final POST, even ones the user never saw. Either disable inputs outside the current step (:disabled="step !== 1") or use x-if/<template> instead of x-show if you need fields fully removed from the submitted payload.Building something more complex than a two-step form? Ask on the Contact page and I'll help you structure it.