State Management
Bidirectional state synchronization between Laravel and Alpine.js. Send state to the server, read it in your controllers, and push updates back to the frontend in real-time.
The State Flow
Gale synchronizes state between your frontend and backend in both directions:
Frontend → Backend
When you call $action(), Gale serializes your
Alpine x-data state and sends it as JSON.
Access it via request()->state()
Backend → Frontend
Call gale()->state() to push updates back.
Changes are streamed via SSE and merged into Alpine's reactive state.
Alpine's reactivity automatically updates the UI.
RFC 7386 JSON Merge Patch
Sending State to Server
Control which state properties are sent to the server when making requests.
The x-sync Directive
The x-sync directive gives you declarative control over which state properties
get sent to the server. Add it to your component to enable state synchronization.
Default Behavior (v2.0+)
x-sync to enable state synchronization, or use include options per-request.
| Syntax | Behavior | Use Case |
|---|---|---|
x-sync |
Send all serializable state | Sync everything from this component |
x-sync="*" |
Send all serializable state (explicit) | Same as empty directive |
x-sync="['a','b']" |
Send only specified properties | Whitelist specific keys |
x-sync="a, b" |
Send only specified properties (shorthand) | Cleaner syntax for simple lists |
| No directive | Send nothing by default | Use include option per-request |
<!-- Sync all state -->
<div x-data="{ name: '', email: '', phone: '' }" x-sync>
<button @click="$action('/save')">Save</button>
</div>
<!-- Sync only specific properties -->
<div x-data="{ name: '', email: '', _uiState: 'form' }"
x-sync="name, email">
<!-- Only name and email are sent -->
<button @click="$action('/save')">Save</button>
</div>
<!-- Sync all state -->
<div x-data="{ name: '', email: '', phone: '' }" x-sync>
<button @click="$action('/save')">Save</button>
</div>
<!-- Sync only specific properties -->
<div x-data="{ name: '', email: '', _uiState: 'form' }"
x-sync="name, email">
<!-- Only name and email are sent -->
<button @click="$action('/save')">Save</button>
</div>
Browser-Only State
Properties prefixed with an underscore (_) are never sent to the server,
even with x-sync. Use this for UI state that doesn't need backend processing:
<div x-data="{
name: '', // Sent to server
email: '', // Sent to server
_showDropdown: false, // Browser only (underscore prefix)
_editMode: false, // Browser only
_cachedResults: [] // Browser only
}" x-sync>
<!-- Only name and email are sent -->
</div>
<div x-data="{
name: '', // Sent to server
email: '', // Sent to server
_showDropdown: false, // Browser only (underscore prefix)
_editMode: false, // Browser only
_cachedResults: [] // Browser only
}" x-sync>
<!-- Only name and email are sent -->
</div>
Common Browser-Only State
Per-Request Control with include/exclude
For request-specific control, use the include or exclude options.
These override x-sync for that specific request.
<!-- Include: Only send specific properties -->
<button @click="$action('/save', { include: ['name', 'email'] })">
Save Contact
</button>
<!-- Exclude: Send everything except specified properties -->
<button @click="$action('/save', { exclude: ['password', 'tempData'] })">
Save User
</button>
<!-- Include: Only send specific properties -->
<button @click="$action('/save', { include: ['name', 'email'] })">
Save Contact
</button>
<!-- Exclude: Send everything except specified properties -->
<button @click="$action('/save', { exclude: ['password', 'tempData'] })">
Save User
</button>
Reading State on Server
Access the frontend's Alpine state using the request()->state() macro.
Get All State
Call without arguments to get the entire state object:
// Frontend: x-data="{ count: 5, user: { name: 'John' } }"
$state = request()->state();
// Returns: ['count' => 5, 'user' => ['name' => 'John']]
// Frontend: x-data="{ count: 5, user: { name: 'John' } }"
$state = request()->state();
// Returns: ['count' => 5, 'user' => ['name' => 'John']]
Get a Single Value
Pass a key to get a specific value, with an optional default:
// Get a value
$count = request()->state('count');
// With a default if the key doesn't exist
$count = request()->state('count', 0);
$page = request()->state('page', 1);
// Get a value
$count = request()->state('count');
// With a default if the key doesn't exist
$count = request()->state('count', 0);
$page = request()->state('page', 1);
Dot Notation for Nested Values
Access nested values using Laravel's familiar dot notation:
// Frontend: x-data="{ user: { profile: { name: 'John', email: null } } }"
$name = request()->state('user.profile.name');
// Returns: 'John'
$email = request()->state('user.profile.email', 'guest@example.com');
// Returns default since email is null
// Frontend: x-data="{ user: { profile: { name: 'John', email: null } } }"
$name = request()->state('user.profile.name');
// Returns: 'John'
$email = request()->state('user.profile.email', 'guest@example.com');
// Returns default since email is null
Tip
state() method uses Laravel's data_get() helper under the hood, so you get the same behavior you're used to.
Updating State from Server
Use gale()->state() to push state updates to the frontend.
Single Value
return gale()->state('count', 42);
return gale()->state('count', 42);
Batch Updates
Pass an associative array to update multiple keys at once:
return gale()->state([
'count' => 42,
'status' => 'complete',
'loading' => false,
]);
return gale()->state([
'count' => 42,
'status' => 'complete',
'loading' => false,
]);
Nested State Updates
Update nested properties using dot notation or nested arrays:
// Using dot notation (updates only the specified key)
return gale()
->state('user.profile.name', 'Jane')
->state('settings.theme', 'dark');
// Using nested arrays (RFC 7386 merge)
return gale()->state([
'user' => [
'profile' => ['name' => 'Jane'] // Other profile keys preserved
]
]);
// Using dot notation (updates only the specified key)
return gale()
->state('user.profile.name', 'Jane')
->state('settings.theme', 'dark');
// Using nested arrays (RFC 7386 merge)
return gale()->state([
'user' => [
'profile' => ['name' => 'Jane'] // Other profile keys preserved
]
]);
RFC 7386 Merge Semantics
Understanding how state merges is crucial. Here are the key rules:
| Current State | Patch Sent | Result | Rule |
|---|---|---|---|
{ a: 1 } |
{ b: 2 } |
{ a: 1, b: 2 } |
New keys added |
{ a: 1 } |
{ a: 2 } |
{ a: 2 } |
Existing keys updated |
{ a: 1, b: 2 } |
{ a: null } |
{ b: 2 } |
null deletes key |
{ a: { b: 1 } } |
{ a: { c: 2 } } |
{ a: { b: 1, c: 2 } } |
Objects merge recursively |
{ a: [1, 2] } |
{ a: [3] } |
{ a: [3] } |
Arrays replaced! |
Arrays Are Replaced, Not Merged
// Correct: Get current array, modify, send complete array
$items = request()->state('items', []);
$items[] = ['id' => uniqid(), 'title' => $title];
return gale()->state('items', $items);
// Correct: Get current array, modify, send complete array
$items = request()->state('items', []);
$items[] = ['id' => uniqid(), 'title' => $title];
return gale()->state('items', $items);
Deleting State
Remove keys from frontend state using gale()->forget():
// Delete a single key
return gale()->forget('temporaryData');
// Delete multiple keys
return gale()->forget(['error', 'warning', 'tempValue']);
// Delete nested keys
return gale()->forget('form.errors');
// Delete a single key
return gale()->forget('temporaryData');
// Delete multiple keys
return gale()->forget(['error', 'warning', 'tempValue']);
// Delete nested keys
return gale()->forget('form.errors');
How Deletion Works
forget() sends a null value. Per RFC 7386, null values remove keys from the target object.
Component State
Work with named components using x-component for targeted state updates
and cross-component communication.
The x-component Directive
Register a component with a name to enable targeted state updates:
<!-- Register component with a name -->
<div x-data="{ items: [], total: 0 }"
x-component="cart"
x-sync>
<!-- Cart contents -->
</div>
<!-- Register component with a name -->
<div x-data="{ items: [], total: 0 }"
x-component="cart"
x-sync>
<!-- Cart contents -->
</div>
Updating Component State
Use gale()->componentState() to update a specific named component:
// Update the 'cart' component specifically
return gale()->componentState('cart', [
'items' => $cartItems,
'total' => $cartTotal,
]);
// Update the 'cart' component specifically
return gale()->componentState('cart', [
'items' => $cartItems,
'total' => $cartTotal,
]);
Including Component State in Requests
Use includeComponents to send state from other named components:
<!-- Include cart and shipping components in checkout -->
<button @click="$action('/checkout', {
includeComponents: ['cart', 'shipping']
})">
Place Order
</button>
<!-- Include only specific properties -->
<button @click="$action('/checkout', {
includeComponents: {
cart: ['items', 'total'],
shipping: true
}
})">
Place Order
</button>
<!-- Include cart and shipping components in checkout -->
<button @click="$action('/checkout', {
includeComponents: ['cart', 'shipping']
})">
Place Order
</button>
<!-- Include only specific properties -->
<button @click="$action('/checkout', {
includeComponents: {
cart: ['items', 'total'],
shipping: true
}
})">
Place Order
</button>
On the server, access component state via request()->state('_components.cart.items').
Connection State ($gale)
The $gale magic property provides real-time connection state
for loading indicators, error handling, and retry feedback.
| Property | Type | Description |
|---|---|---|
$gale.loading |
boolean | True if any request is in progress |
$gale.activeCount |
number | Number of active requests |
$gale.retrying |
boolean | True if a request is retrying after failure |
$gale.retriesFailed |
boolean | True if all retries exhausted |
$gale.error |
string|null | Most recent error message |
$gale.errors |
array | All accumulated error messages |
$gale.clearErrors() |
function | Clear all error messages |
<div x-data="{ query: '' }" x-sync>
<input type="text" x-model="query"
@input.debounce.300ms="$action('/search')">
<!-- Loading indicator -->
<span x-show="$gale.loading">Searching...</span>
<!-- Retry indicator -->
<span x-show="$gale.retrying" class="text-amber-500">
Reconnecting...
</span>
<!-- Error display -->
<div x-show="$gale.error" class="text-red-500">
<span x-text="$gale.error"></span>
<button @click="$gale.clearErrors()">Dismiss</button>
</div>
</div>
<div x-data="{ query: '' }" x-sync>
<input type="text" x-model="query"
@input.debounce.300ms="$action('/search')">
<!-- Loading indicator -->
<span x-show="$gale.loading">Searching...</span>
<!-- Retry indicator -->
<span x-show="$gale.retrying" class="text-amber-500">
Reconnecting...
</span>
<!-- Error display -->
<div x-show="$gale.error" class="text-red-500">
<span x-text="$gale.error"></span>
<button @click="$gale.clearErrors()">Dismiss</button>
</div>
</div>
Per-Scope Loading ($fetching)
Use $fetching() to track loading state for specific actions or scopes,
enabling granular loading indicators within a component.
<div x-data="{ name: '', email: '' }" x-sync>
<!-- Save button with scoped loading -->
<button @click="$action('/save', { scope: 'save' })"
:disabled="$fetching('save')">
<span x-show="!$fetching('save')">Save</span>
<span x-show="$fetching('save')">Saving...</span>
</button>
<!-- Delete button with different scope -->
<button @click="$action('/delete', { scope: 'delete' })"
:disabled="$fetching('delete')">
<span x-show="!$fetching('delete')">Delete</span>
<span x-show="$fetching('delete')">Deleting...</span>
</button>
</div>
<div x-data="{ name: '', email: '' }" x-sync>
<!-- Save button with scoped loading -->
<button @click="$action('/save', { scope: 'save' })"
:disabled="$fetching('save')">
<span x-show="!$fetching('save')">Save</span>
<span x-show="$fetching('save')">Saving...</span>
</button>
<!-- Delete button with different scope -->
<button @click="$action('/delete', { scope: 'delete' })"
:disabled="$fetching('delete')">
<span x-show="!$fetching('delete')">Delete</span>
<span x-show="$fetching('delete')">Deleting...</span>
</button>
</div>
Practical Examples
Live Search with Debounce
<!-- Frontend -->
<div x-data="{ query: '', results: [] }" x-sync="query">
<input type="text"
x-model="query"
@input.debounce.300ms="$action('/search')"
placeholder="Search...">
<span x-show="$gale.loading">Searching...</span>
<ul>
<template x-for="result in results" :key="result.id">
<li x-text="result.title"></li>
</template>
</ul>
</div>
<!-- Frontend -->
<div x-data="{ query: '', results: [] }" x-sync="query">
<input type="text"
x-model="query"
@input.debounce.300ms="$action('/search')"
placeholder="Search...">
<span x-show="$gale.loading">Searching...</span>
<ul>
<template x-for="result in results" :key="result.id">
<li x-text="result.title"></li>
</template>
</ul>
</div>
// Backend
Route::post('/search', function () {
$query = request()->state('query', '');
if (strlen($query) < 2) {
return gale()->state('results', []);
}
$results = Product::query()
->where('title', 'like', "%{$query}%")
->limit(10)
->get(['id', 'title']);
return gale()->state('results', $results);
});
// Backend
Route::post('/search', function () {
$query = request()->state('query', '');
if (strlen($query) < 2) {
return gale()->state('results', []);
}
$results = Product::query()
->where('title', 'like', "%{$query}%")
->limit(10)
->get(['id', 'title']);
return gale()->state('results', $results);
});
Shopping Cart with Cross-Component Updates
<!-- Cart header component -->
<div x-data="{ count: 0, total: 0 }"
x-component="cart-header">
Cart (<span x-text="count"></span>) - $<span x-text="total"></span>
</div>
<!-- Product listing -->
<div x-data="{ productId: 123 }" x-sync>
<button @click="$action('/cart/add')">
Add to Cart
</button>
</div>
<!-- Cart header component -->
<div x-data="{ count: 0, total: 0 }"
x-component="cart-header">
Cart (<span x-text="count"></span>) - $<span x-text="total"></span>
</div>
<!-- Product listing -->
<div x-data="{ productId: 123 }" x-sync>
<button @click="$action('/cart/add')">
Add to Cart
</button>
</div>
// Backend - Update both components
public function addToCart(Request $request)
{
$productId = $request->state('productId');
$cart = Cart::add($productId);
return gale()
->componentState('cart-header', [
'count' => $cart->count(),
'total' => $cart->total(),
])
->toast('Added to cart!');
}
// Backend - Update both components
public function addToCart(Request $request)
{
$productId = $request->state('productId');
$cart = Cart::add($productId);
return gale()
->componentState('cart-header', [
'count' => $cart->count(),
'total' => $cart->total(),
])
->toast('Added to cart!');
}
Quick Reference
| Method / Directive | Description |
|---|---|
x-sync |
Enable state synchronization for a component |
x-component="name" |
Register a named component |
request()->state() |
Get all frontend state as array |
request()->state('key', $default) |
Get specific key with optional default |
gale()->state('key', $value) |
Update a single state key |
gale()->state([...]) |
Update multiple state keys |
gale()->forget('key') |
Delete state key(s) |
gale()->componentState('name', [...]) |
Update a named component's state |
$gale.loading |
Check if any request is active |
$fetching('scope') |
Check if specific scope is loading |
Best Practices
Keep State Minimal
Only include what's necessary in x-data.
Large state objects increase request payload size.
Always Use Defaults
Provide defaults with request()->state('key', default)
to handle missing keys gracefully.
Validate User Input
Never trust frontend state. Use Laravel's validation for any user-provided data.
Batch Related Updates
Group related state changes in a single state([])
call for cleaner code and fewer SSE events.