The problem: A handful of Alpine.js mistakes keep showing up across different Laravel projects — not because Alpine is confusing, but because its behavior around scope, reactivity, and Livewire interop has a few non-obvious edges that aren't covered in the official docs' quick-start examples.
Environment: Laravel 11, Alpine.js 3.x (Livewire-bundled), Blade views, Vite 5.
The Error
// These mistakes rarely throw one consistent error.
// Most common console noise across all of them:
Alpine Expression Error: [property] is not defined
Expression: "[property]"
// or simply silent wrong behavior with no console output at all.
Why It Happens
Five mistakes account for the majority of Alpine bugs reported in Laravel + Livewire projects:
1. Mutating array/object state without triggering reactivity properly. Direct index assignment like items[0] = newValue or mutating a nested object property Alpine hasn't seen before can behave inconsistently depending on how the object was initialized.
2. Referencing a property from the wrong scope. Alpine expressions inside nested x-data, x-for, or template blocks only see properties from their own scope and ancestor scopes — not sibling components.
3. Using x-html with unescaped user input. This renders raw HTML directly, which is a stored XSS risk if the content includes anything a user submitted without sanitization — Blade's {{ }} already escapes by default, but x-html bypasses that entirely.
4. Forgetting that x-if and x-show behave differently for initialization. x-show renders the element immediately (just hidden via CSS) so its x-init/refs exist right away; x-if doesn't render the element into the DOM at all until true, so anything depending on that element existing has to wait.
5. Not accounting for Livewire re-renders resetting local Alpine state. If Alpine state should persist across a Livewire update, initializing it purely inline (rather than seeding from a Livewire property) means it resets to its default every time Livewire re-renders that part of the DOM.
The Fix
For #1 — use array/object methods Alpine tracks reliably (push, splice, spreading into a new array) rather than raw index assignment when in doubt:
// Prefer this:
this.items = [...this.items, newItem];
this.items.splice(index, 1);
// Over direct index mutation when the array was
// built dynamically rather than declared inline
For #2 — if two components genuinely need the same data, use Alpine.store() (see the earlier post on sharing state) rather than trying to reach across sibling scopes.
For #3 — sanitize on the server before it ever reaches x-html, and default to x-text wherever raw HTML rendering isn't actually required:
<!-- Only if you've already sanitized server-side, e.g. with
a package like HTMLPurifier, and truly need HTML output -->
<div x-html="sanitizedDescription"></div>
<!-- Otherwise, always prefer: -->
<div x-text="description"></div>
For #4 — choose deliberately: x-show when you need the element and its refs to exist immediately (just visually hidden), x-if/template when the field truly shouldn't exist in the submitted DOM until the condition is met:
<!-- Needs to exist immediately for $refs/focus to work: -->
<input x-show="showField" x-ref="field">
<!-- Genuinely shouldn't exist in the DOM/be submitted until true: -->
<template x-if="showField">
<input name="conditional_field">
</template>
For #5 — seed Alpine state from a Livewire/PHP value instead of a hardcoded default, so a re-render doesn't silently reset UI state the user already set:
<div x-data="{ expanded: {{ $userPreference ? 'true' : 'false' }} }">
...
</div>
Step-by-Step
- Search your codebase for direct array index assignments (
arr[i] = ...) inside Alpine scopes and replace withsplice/spread patterns where the array is built dynamically. - Search for any
x-htmlusage and confirm the bound value is sanitized server-side, not raw user input. - For every conditional field, decide deliberately between
x-showandx-ifbased on whether it needs to exist in the DOM/submitted payload before the condition is true. - For state that should survive a Livewire re-render, confirm it's seeded from a PHP/Livewire value in
x-data, not hardcoded to a default. - Check any place two components seem to need the same data — replace ad hoc cross-scope references with a proper
Alpine.store(). - Rebuild with
npm run buildand manually retest each fixed area, including a Livewire re-render trigger where relevant.
x-html with unsanitized input) is the one worth treating as a security issue, not just a bug — if any user-submitted field (a bio, comment, or profile description) ever reaches x-html without passing through server-side sanitization first, audit that path immediately rather than deferring it, since it's a real stored XSS vector, not a cosmetic mistake.Found a different Alpine gotcha not listed here? Share it on the Contact page — I might turn it into a future post.