State Serialization
When HTTP magics send requests, Gale serializes Alpine state into JSON. In v2.0, serialization only happens for state you explicitly opt in to send.
v2.0 Breaking Change
x-sync (component-wide) or include (per request) to opt in.
How Serialization Works
When you call an HTTP magic like $action('/save'), Gale collects only the state
you opt in to send, then serializes it.
- Component x-data - Properties included via
x-syncorinclude - Parent scopes - State from parent components in nested structures
- Global stores - Alpine.store() data (if any)
- Named components - Other components when using
includeComponents
Then it filters and converts this state to JSON that's safe to send over HTTP.
Automatic Filtering
Certain properties are automatically excluded from serialization:
| Type | Example | Reason |
|---|---|---|
| Underscore prefix | _isLoading |
Browser-only state |
| Dollar prefix | $el, $refs |
Alpine internals |
| Functions | save(), validate() |
Cannot serialize functions |
| DOM Elements | this.$refs.input |
Cannot serialize DOM nodes |
<div x-data="{
// SENT to server
name: 'John',
email: 'john@example.com',
preferences: { theme: 'dark' },
// NOT sent (underscore = browser-only)
_isEditing: false,
_selectedTab: 'profile',
// NOT sent (functions excluded)
save() { this.$action('/save') },
validate() { return this.name.length > 0 }
}" x-sync>
<!-- Request body: {"name":"John","email":"john@example.com","preferences":{"theme":"dark"}} -->
<button @click="$action('/save')">Save</button>
</div>
<div x-data="{
// SENT to server
name: 'John',
email: 'john@example.com',
preferences: { theme: 'dark' },
// NOT sent (underscore = browser-only)
_isEditing: false,
_selectedTab: 'profile',
// NOT sent (functions excluded)
save() { this.$action('/save') },
validate() { return this.name.length > 0 }
}" x-sync>
<!-- Request body: {"name":"John","email":"john@example.com","preferences":{"theme":"dark"}} -->
<button @click="$action('/save')">Save</button>
</div>
Controlling State with x-sync
The x-sync directive gives you declarative control over which state properties get sent to the server.
This is more convenient than using include options when you want the same synchronization behavior for all requests from a component.
Syntax Variants
| Syntax | Behavior | Use Case |
|---|---|---|
x-sync |
Send all serializable state (wildcard) | 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 |
Examples
Sync All State
<div x-data="{ name: '', email: '', phone: '' }" x-sync>
<!-- All properties (name, email, phone) will be sent -->
<button @click="$action('/save')">Save</button>
</div>
<div x-data="{ name: '', email: '', phone: '' }" x-sync>
<!-- All properties (name, email, phone) will be sent -->
<button @click="$action('/save')">Save</button>
</div>
Sync Specific Properties (Array Syntax)
<div x-data="{
name: '',
email: '',
password: '', // Won't be sent
confirmPassword: '', // Won't be sent
_uiState: 'form' // Won't be sent (underscore)
}" x-sync="['name', 'email']">
<!-- Only name and email will be sent -->
<button @click="$action('/register')">Register</button>
</div>
<div x-data="{
name: '',
email: '',
password: '', // Won't be sent
confirmPassword: '', // Won't be sent
_uiState: 'form' // Won't be sent (underscore)
}" x-sync="['name', 'email']">
<!-- Only name and email will be sent -->
<button @click="$action('/register')">Register</button>
</div>
Sync Specific Properties (String Syntax)
<div x-data="{ title: '', description: '', status: '' }"
x-sync="title, description">
<!-- Only title and description will be sent (status excluded) -->
<button @click="$action('/tasks')">Create Task</button>
</div>
<div x-data="{ title: '', description: '', status: '' }"
x-sync="title, description">
<!-- Only title and description will be sent (status excluded) -->
<button @click="$action('/tasks')">Create Task</button>
</div>
Interaction with include/exclude
When using x-sync with include or exclude options, the option takes precedence for that specific request:
<div x-data="{ a: 1, b: 2, c: 3 }" x-sync="a, b">
<!-- Normally sends {a: 1, b: 2} -->
<button @click="$action('/save')">Save A & B</button>
<!-- Override: sends {c: 3} only -->
<button @click="$action('/save', { include: ['c'] })">Save C Only</button>
</div>
<div x-data="{ a: 1, b: 2, c: 3 }" x-sync="a, b">
<!-- Normally sends {a: 1, b: 2} -->
<button @click="$action('/save')">Save A & B</button>
<!-- Override: sends {c: 3} only -->
<button @click="$action('/save', { include: ['c'] })">Save C Only</button>
</div>
Best Practice
x-sync for component-wide synchronization behavior, and include/exclude options for request-specific overrides.
Special Type Handling
Gale intelligently converts JavaScript types to JSON-compatible formats:
Dates
JavaScript Date objects are converted to ISO 8601 strings:
// JavaScript
{ createdAt: new Date('2024-01-15T10:30:00') }
// Serialized JSON
{ "createdAt": "2024-01-15T10:30:00.000Z" }
// JavaScript
{ createdAt: new Date('2024-01-15T10:30:00') }
// Serialized JSON
{ "createdAt": "2024-01-15T10:30:00.000Z" }
In Laravel, use Carbon to parse:
use Carbon\Carbon;
$createdAt = Carbon::parse($request->input('createdAt'));
use Carbon\Carbon;
$createdAt = Carbon::parse($request->input('createdAt'));
Maps and Sets
Map objects become plain objects, and Set objects become arrays:
// JavaScript Map
const settings = new Map([
['theme', 'dark'],
['language', 'en']
]);
// Serialized as: {"theme":"dark","language":"en"}
// JavaScript Set
const selectedIds = new Set([1, 2, 3]);
// Serialized as: [1,2,3]
// JavaScript Map
const settings = new Map([
['theme', 'dark'],
['language', 'en']
]);
// Serialized as: {"theme":"dark","language":"en"}
// JavaScript Set
const selectedIds = new Set([1, 2, 3]);
// Serialized as: [1,2,3]
Regular Expressions
RegExp objects are converted to their string representation:
// JavaScript
{ pattern: /^\d{3}-\d{4}$/ }
// Serialized JSON
{ "pattern": "/^\\d{3}-\\d{4}$/" }
// JavaScript
{ pattern: /^\d{3}-\d{4}$/ }
// Serialized JSON
{ "pattern": "/^\\d{3}-\\d{4}$/" }
Nested Objects and Arrays
Gale recursively serializes nested objects and arrays to any depth (up to safety limits):
<div x-data="{
user: {
profile: {
name: 'John',
address: {
street: '123 Main St',
city: 'Boston'
}
},
settings: {
notifications: true
}
},
items: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]
}">
<!-- Full nested structure is serialized -->
<button @click="$action('/save')">Save</button>
</div>
<div x-data="{
user: {
profile: {
name: 'John',
address: {
street: '123 Main St',
city: 'Boston'
}
},
settings: {
notifications: true
}
},
items: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]
}">
<!-- Full nested structure is serialized -->
<button @click="$action('/save')">Save</button>
</div>
Circular Reference Protection
Gale detects and handles circular references to prevent infinite loops:
// If your state has circular references:
const parent = { name: 'Parent' };
const child = { name: 'Child', parent: parent };
parent.child = child; // Circular!
// Serialized result:
{
"name": "Parent",
"child": {
"name": "Child",
"parent": "[Circular]" // Replaced with marker
}
}
// If your state has circular references:
const parent = { name: 'Parent' };
const child = { name: 'Child', parent: parent };
parent.child = child; // Circular!
// Serialized result:
{
"name": "Parent",
"child": {
"name": "Child",
"parent": "[Circular]" // Replaced with marker
}
}
Safety Limits
To prevent memory exhaustion and stack overflows, Gale enforces these limits:
| Limit | Value | When Exceeded |
|---|---|---|
| Max Depth | 50 levels | Value replaced with [MaxDepth] |
| Max Keys | 10,000 keys | Remaining keys replaced with [MaxKeys] |
| Max String Length | 100,000 chars | String truncated with ...[truncated] |
include option to send only
what's needed.
Handling Alpine Proxies
Alpine wraps your data in Proxy objects for reactivity. Gale automatically "collapses" these proxies to extract plain values:
// Alpine internally stores:
Proxy {
items: Proxy { 0: Proxy {...}, 1: Proxy {...} }
}
// Gale extracts plain values:
{
"items": [
{ "id": 1, "name": "Item 1" },
{ "id": 2, "name": "Item 2" }
]
}
// Alpine internally stores:
Proxy {
items: Proxy { 0: Proxy {...}, 1: Proxy {...} }
}
// Gale extracts plain values:
{
"items": [
{ "id": 1, "name": "Item 1" },
{ "id": 2, "name": "Item 2" }
]
}
Parent Scope Merging
When components are nested, Gale collects state from all parent scopes:
<div x-data="{ userId: 123, userName: 'John' }" x-sync>
<div x-data="{ postId: 456, postTitle: 'Hello' }" x-sync>
<!-- Request includes state from BOTH parent and child -->
<button @click="$action('/save')">Save</button>
<!-- Serialized: {"userId":123,"userName":"John","postId":456,"postTitle":"Hello"} -->
</div>
</div>
<div x-data="{ userId: 123, userName: 'John' }" x-sync>
<div x-data="{ postId: 456, postTitle: 'Hello' }" x-sync>
<!-- Request includes state from BOTH parent and child -->
<button @click="$action('/save')">Save</button>
<!-- Serialized: {"userId":123,"userName":"John","postId":456,"postTitle":"Hello"} -->
</div>
</div>
Component State Namespace
When using includeComponents, component state is placed under the
_components key to avoid conflicts:
// Request with includeComponents
$action('/checkout', {
includeComponents: ['cart', 'shipping']
})
// Serialized structure:
{
// Local component state
"paymentMethod": "card",
// Named components under _components
"_components": {
"cart": {
"items": [...],
"total": 99.99
},
"shipping": {
"address": "123 Main St",
"method": "express"
}
}
}
// Request with includeComponents
$action('/checkout', {
includeComponents: ['cart', 'shipping']
})
// Serialized structure:
{
// Local component state
"paymentMethod": "card",
// Named components under _components
"_components": {
"cart": {
"items": [...],
"total": 99.99
},
"shipping": {
"address": "123 Main St",
"method": "express"
}
}
}
Debugging Serialization
To see what gets serialized, you can log the request body in your controller:
public function debug(Request $request)
{
// Log all received state
Log::info('Received state:', $request->all());
// Or dump in development
dd($request->all());
return gale();
}
public function debug(Request $request)
{
// Log all received state
Log::info('Received state:', $request->all());
// Or dump in development
dd($request->all());
return gale();
}
You can also inspect the Network tab in browser DevTools to see the request payload.
Best Practices
These patterns assume the component uses x-sync or the request uses include.
1. Use Underscore Prefix for UI State
// Good: Clear separation of concerns
{
formData: { name: '', email: '' }, // Sent
_isSubmitting: false, // Not sent
_errors: {}, // Not sent
_showModal: false // Not sent
}
// Good: Clear separation of concerns
{
formData: { name: '', email: '' }, // Sent
_isSubmitting: false, // Not sent
_errors: {}, // Not sent
_showModal: false // Not sent
}
2. Use Include for Large Components
// Good: Only send what's needed
$action('/update-name', { include: ['name'] })
// Avoid: Sending entire state when only name is needed
$action('/update-name')
// Good: Only send what's needed
$action('/update-name', { include: ['name'] })
// Avoid: Sending entire state when only name is needed
$action('/update-name')
3. Flatten Deep Structures When Possible
// Better: Flat structure
{
name: 'John',
street: '123 Main St',
city: 'Boston'
}
// Deeply nested (harder to validate, more bytes)
{
user: {
profile: {
details: {
name: 'John',
address: {
street: '123 Main St',
city: 'Boston'
}
}
}
}
}
// Better: Flat structure
{
name: 'John',
street: '123 Main St',
city: 'Boston'
}
// Deeply nested (harder to validate, more bytes)
{
user: {
profile: {
details: {
name: 'John',
address: {
street: '123 Main St',
city: 'Boston'
}
}
}
}
}