| Install | |
|---|---|
composer require dancycodes/gale |
|
| Latest Version: | v0.5.3 |
| PHP: | ^8.3 |
Laravel Gale is a server-driven reactive framework for Laravel. It uses standard HTTP responses (JSON) by default to deliver real-time UI updates to Alpine.js components directly from your Blade templates -- no JavaScript framework, no build complexity, no API layer. For long-running operations or real-time streaming, Server-Sent Events (SSE) is available as an explicit opt-in.
GALE = Gouater + Anais + Loic + Eunice (Founders' initials)
This README documents both:
dancycodes/gale)Full documentation: docs/README.md | Getting Started | Backend API | Frontend API
No Node.js or npm required for basic usage. @gale serves the pre-built JS bundle from public/vendor/gale/.
A complete reactive counter in under 20 lines:
routes/web.php:
Route::get('/counter', fn() => gale()->view('counter', ['count' => 0], web: true));
Route::post('/increment', function () {
return gale()->state('count', request()->state('count', 0) + 1);
});
resources/views/counter.blade.php:
<!DOCTYPE html>
<html>
<head>
@gale
</head>
<body>
<div x-data="{ count: {{ $count }} }" x-sync>
<span x-text="count"></span>
<button @click="$action('/increment')">+</button>
</div>
</body>
</html>
Click the button. The count updates via HTTP. No page reload, no JavaScript written.
composer require dancycodes/gale
php artisan gale:install
Add @gale to your layout's <head>:
<head>
@gale
</head>
That's it. The @gale directive outputs:
APP_DEBUG=true)Gale bundles Alpine.js (v3) with the Morph plugin. If you already have Alpine.js installed, remove it to prevent conflicts:
<!-- Remove any CDN script -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
// Remove these lines from resources/js/app.js:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Then use @gale instead -- it handles everything.
Gale exposes window.Alpine, so other plugins work normally:
<head>
@gale
<script>
document.addEventListener('alpine:init', () => {
Alpine.plugin(yourPlugin);
});
</script>
</head>
php artisan vendor:publish --tag=gale-config
Gale operates in two modes with an identical developer API:
HTTP mode (default): Responses are standard JSON payloads (Content-Type: application/json). Simple, works with all hosting environments, CDNs, and load balancers. Suitable for the vast majority of interactions.
SSE mode (opt-in): Responses are streamed as Server-Sent Events (Content-Type: text/event-stream). Required for long-running operations, real-time progress, or live streaming. Activated per-request with { sse: true } or globally via configuration.
The backend API is identical in both modes -- the same gale()->state(), gale()->view(), and all other methods work regardless of transport. The frontend automatically detects the response type and processes accordingly.
BROWSER
+----------------------------------------------------+
| Alpine.js Component (x-data) |
| State: { count: 0, user: {...} } |
+----------------------------------------------------+
|
| @click="$action('/increment')"
v
+----------------------------------------------------+
| HTTP Request |
| Headers: Gale-Request: true, X-CSRF-TOKEN |
| Body: { count: 0, user: {...} } |
+----------------------------------------------------+
|
v
LARAVEL SERVER
+----------------------------------------------------+
| Controller |
| $count = request()->state('count'); |
| return gale()->state('count', $count + 1); |
+----------------------------------------------------+
|
+------------+------------+
| |
HTTP Mode SSE Mode
+------------------+ +--------------------+
| application/json | | text/event-stream |
| { events: [...] }| | event: gale-patch |
+------------------+ +--------------------+
| |
+------------+------------+
|
v
+----------------------------------------------------+
| Alpine.js merges state via RFC 7386 |
| State: { count: 1, user: {...} } |
| UI reactively updates |
+----------------------------------------------------+
State updates follow RFC 7386:
| Server Sends | Current State | Result |
|---|---|---|
{ count: 5 } |
{ count: 0, name: "John" } |
{ count: 5, name: "John" } |
{ name: null } |
{ count: 0, name: "John" } |
{ count: 0 } |
{ user: { email: "new" } } |
{ user: { name: "John", email: "old" } } |
{ user: { name: "John", email: "new" } } |
null removes the property| Feature | HTTP Mode (Default) | SSE Mode (Opt-in) |
|---|---|---|
| Transport | Standard JSON over HTTP | Server-Sent Events stream |
| Response type | application/json |
text/event-stream |
| Hosting | Works everywhere | Requires SSE-compatible hosting |
| CDN / Load Balancer | Fully compatible | May require configuration |
| Serverless | Fully compatible | Not recommended |
| Latency | Single response | Streaming (events sent as they occur) |
| Progress updates | Not supported | Real-time progress |
| Long-running ops | Subject to timeout | Stream indefinitely |
| Connection overhead | New connection per request | Held open during stream |
| Error handling | Standard HTTP status codes | Inline error events |
| Retry | Automatic with backoff | Built-in SSE reconnection |
| Best for | Forms, CRUD, navigation, most interactions | Dashboards, progress bars, chat, AI streaming |
Use HTTP mode (default) when:
Use SSE mode when:
The default mode can be set at three levels (highest priority first):
1. Request header (per-request, set automatically by frontend):
Gale-Mode: sse
2. Environment variable (application-wide):
GALE_MODE=http
3. Config file (config/gale.php):
return [
'mode' => env('GALE_MODE', 'http'),
// ...
];
On the frontend, override per request:
<!-- Force SSE for this action -->
<button @click="$action('/process', { sse: true })">Process</button>
<!-- Force HTTP for this action -->
<button @click="$action('/save', { http: true })">Save</button>
Or use gale()->stream() on the backend, which always uses SSE regardless of configuration:
return gale()->stream(function ($gale) {
// This always streams via SSE
$gale->state('progress', 50);
});
Returns a request-scoped GaleResponse instance with a fluent API:
return gale()
->state('count', 42)
->state('updated', now()->toISOString())
->messages(['_success' => 'Saved!']);
The same instance accumulates events throughout the request. In HTTP mode, they are serialized as a single JSON response. In SSE mode, they are streamed as individual events.
Set state values to merge into the Alpine component:
// Single key-value
gale()->state('count', 42);
// Multiple values
gale()->state([
'count' => 42,
'user' => ['name' => 'John', 'email' => 'john@example.com'],
]);
// Nested update (merges with existing)
gale()->state('user.email', 'new@example.com');
// Only set if key doesn't exist in component state
gale()->state('defaults', ['theme' => 'dark'], ['onlyIfMissing' => true]);
Alias for state() when passing an array -- preferred for explicit multi-key patches:
gale()->patchState(['count' => 1, 'updated' => true]);
Remove state properties (sends null per RFC 7386):
gale()->forget('tempData');
gale()->forget(['tempData', 'cache', 'draft']);
Set the messages state object (used for validation errors and notifications):
gale()->messages([
'email' => 'Invalid email address',
'password' => 'Password too short',
]);
// Success pattern
gale()->messages(['_success' => 'Profile saved!']);
Clear all messages:
gale()->clearMessages();
Deliver flash data to both the session and the _flash Alpine state key in one call:
gale()->flash('status', 'Your account has been updated.');
gale()->flash(['status' => 'ok', 'message' => 'Saved!']);
In the view, display flash reactively:
<div x-data="{ _flash: {} }" x-sync="['_flash']">
<div x-show="_flash.status" x-text="_flash.status" class="alert"></div>
</div>
Render a Blade view and patch it into the DOM:
// Morph by matching element IDs
gale()->view('partials.user-card', ['user' => $user]);
// With selector and mode
gale()->view('partials.item', ['item' => $item], [
'selector' => '#items-list',
'mode' => 'append',
]);
// As web fallback for non-Gale requests
gale()->view('dashboard', $data, web: true);
Patch raw HTML into the DOM:
gale()->html('<div id="content">New content</div>');
gale()->html('<li>New item</li>', [
'selector' => '#list',
'mode' => 'append',
]);
// Server-driven state (replacement via initTree)
gale()->outer('#element', '<div id="element">Replaced</div>');
gale()->inner('#container', '<p>Inner content</p>');
// Client-preserved state (smart morphing via Alpine.morph)
gale()->outerMorph('#element', '<div id="element">Updated</div>');
gale()->innerMorph('#container', '<p>Morphed content</p>');
// Insertion modes
gale()->append('#list', '<li>Last</li>');
gale()->prepend('#list', '<li>First</li>');
gale()->before('#target', '<div>Before</div>');
gale()->after('#target', '<div>After</div>');
// Removal
gale()->remove('.deprecated');
// Viewport modifiers (optional third parameter)
gale()->append('#chat', $html, ['scroll' => 'bottom']);
gale()->outer('#form', $html, ['show' => 'top']);
| Method | Mode | State Handling |
|---|---|---|
outer($selector, $html, $opts) |
outer |
Server-driven |
inner($selector, $html, $opts) |
inner |
Server-driven |
outerMorph($selector, $html, $opts) |
outerMorph |
Client-preserved |
innerMorph($selector, $html, $opts) |
innerMorph |
Client-preserved |
append($selector, $html, $opts) |
append |
New elements init |
prepend($selector, $html, $opts) |
prepend |
New elements init |
before($selector, $html, $opts) |
before |
New elements init |
after($selector, $html, $opts) |
after |
New elements init |
remove($selector) |
remove |
Cleanup |
View options:
| Option | Type | Default | Description |
|---|---|---|---|
selector |
string | null |
CSS selector for target element |
mode |
string | 'outer' |
DOM patching mode |
useViewTransition |
bool | false |
Enable View Transitions API |
settle |
int | 0 |
Delay (ms) before patching |
scroll |
string | null |
Auto-scroll: 'top' or 'bottom' |
show |
string | null |
Scroll into viewport: 'top' or 'bottom' |
focusScroll |
bool | false |
Maintain focus scroll position |
Extract and render specific sections from Blade views without rendering the entire template.
Define fragments in Blade:
<div id="todo-list">
@fragment('todo-items')
@foreach($todos as $todo)
<div id="todo-{{ $todo->id }}">{{ $todo->title }}</div>
@endforeach
@endfragment
</div>
Render fragments:
// Single fragment
gale()->fragment('todos', 'todo-items', ['todos' => $todos]);
// With options
gale()->fragment('todos', 'todo-items', ['todos' => $todos], [
'selector' => '#todo-list',
'mode' => 'morph',
]);
// Multiple fragments at once
gale()->fragments([
['view' => 'dashboard', 'fragment' => 'stats', 'data' => $statsData],
['view' => 'dashboard', 'fragment' => 'recent-orders', 'data' => $ordersData],
]);
Full-page browser redirects with session flash support:
return gale()->redirect('/dashboard');
return gale()->redirect('/dashboard')
->with('message', 'Welcome back!')
->with(['key' => 'value']);
return gale()->redirect('/register')
->withErrors($validator)
->withInput();
| Method | Description |
|---|---|
with($key, $value) |
Flash data to session |
withInput($input) |
Flash form input for repopulation |
withErrors($errors) |
Flash validation errors |
back($fallback) |
Redirect to previous URL with fallback |
backOr($route, $params) |
Back with named route fallback |
refresh($query, $fragment) |
Reload current page |
home() |
Redirect to root URL |
route($name, $params) |
Redirect to named route |
intended($default) |
Redirect to auth intended URL |
forceReload($bypass) |
Hard reload via JavaScript |
Trigger SPA navigation from the backend:
gale()->navigate('/users');
gale()->navigate('/users', 'main-content');
// Merge query params
gale()->navigateMerge(['page' => 2]);
// Replace history instead of push
gale()->navigateReplace('/users');
// Update query parameters in place
gale()->updateQueries(['sort' => 'name', 'order' => 'asc']);
// Clear specific query parameters
gale()->clearQueries(['filter', 'search']);
// Full page reload
gale()->reload();
Dispatch custom DOM events from the server:
gale()->dispatch('user-updated', ['id' => $user->id]);
// Targeted to a specific element
gale()->dispatch('refresh', ['section' => 'cart'], [
'selector' => '.shopping-cart',
]);
Listen in Alpine:
<div x-data @user-updated.window="handleUpdate($event.detail)"></div>
Execute JavaScript in the browser:
gale()->js('console.log("Hello from server")');
gale()->js('myApp.showNotification("Saved!")');
Send debug data to the Gale debug panel (dev mode only):
gale()->debug('payload', $request->all());
gale()->debug(['user' => $user, 'state' => $state]);
Target specific named Alpine components from the backend:
// Update a component's state
gale()->componentState('cart', [
'items' => $cartItems,
'total' => $total,
]);
// Invoke a method on a named component
gale()->componentMethod('cart', 'recalculate');
gale()->componentMethod('calculator', 'setValues', [10, 20, 30]);
For long-running operations, stream events in real-time. gale()->stream() always uses SSE regardless of the global mode setting:
return gale()->stream(function ($gale) {
$users = User::cursor();
$total = User::count();
$processed = 0;
foreach ($users as $user) {
$user->processExpensiveOperation();
$processed++;
// Sent immediately to the browser
$gale->state('progress', [
'current' => $processed,
'total' => $total,
'percent' => round(($processed / $total) * 100),
]);
}
$gale->state('complete', true);
$gale->messages(['_success' => "Processed {$total} users"]);
});
Gale registers these macros on the Laravel Request object:
// Check if the request is a Gale request
if (request()->isGale()) {
return gale()->state('data', $data);
}
return view('page', compact('data'));
// Access state sent from the Alpine component
$count = request()->state('count', 0);
$email = request()->state('user.email');
// Check if it's a navigation request
if (request()->isGaleNavigate()) {
return gale()->fragment('page', 'content', $data);
}
// Validate state with automatic error response
$validated = request()->validateState([
'email' => 'required|email',
'name' => 'required|min:2',
]);
Include the JavaScript bundle and CSRF meta tag:
<head>
@gale
</head>
Accepts optional options:
@gale(['nonce' => config('gale.csp_nonce')])
Define extractable fragments:
@fragment('header')
<header>{{ $title }}</header>
@endfragment
Conditional rendering based on request type:
@ifgale
<div id="content">{{ $content }}</div>
@else
@include('layouts.app')
@endifgale
Standard Laravel validation works reactively for Gale requests. ValidationException is automatically converted to a gale()->messages() response:
// Standard validate() -- auto-converts for Gale requests
public function store(Request $request)
{
$request->validate([
'state.name' => 'required|min:2|max:255',
'state.email' => 'required|email|unique:users',
]);
// Process...
}
// validateState() -- validates against component state directly
public function store(Request $request)
{
$validated = $request->validateState([
'name' => 'required|min:2|max:255',
'email' => 'required|email|unique:users',
]);
User::create($validated);
return gale()->messages(['_success' => 'Account created!']);
}
Form Request classes also work out of the box:
// app/Http/Requests/StoreUserRequest.php
class StoreUserRequest extends FormRequest
{
public function rules(): array
{
return [
'state.name' => 'required|min:2',
'state.email' => 'required|email',
];
}
}
// Controller -- validation errors auto-converted for Gale
public function store(StoreUserRequest $request)
{
User::create($request->validated());
return gale()->messages(['_success' => 'Created!']);
}
gale()->when($condition, function ($gale) {
$gale->state('visible', true);
});
gale()->whenGale(
fn($g) => $g->state('partial', true),
fn($g) => $g->web(view('full'))
);
gale()->whenGaleNavigate('sidebar', function ($gale) {
$gale->fragment('layout', 'sidebar', $data);
});
// Web fallback for non-Gale requests
return gale()
->state('data', $data)
->web(view('page', compact('data')));
Optional attribute-based route discovery:
// config/gale.php
'route_discovery' => [
'enabled' => true,
'discover_controllers_in_directory' => [
app_path('Http/Controllers'),
],
],
use Dancycodes\Gale\Routing\Attributes\Route;
use Dancycodes\Gale\Routing\Attributes\Prefix;
use Dancycodes\Gale\Routing\Attributes\Group;
use Dancycodes\Gale\Routing\Attributes\Middleware;
#[Prefix('/admin')]
class UserController extends Controller
{
#[Route('GET', '/users', name: 'admin.users.index')]
public function index() { }
#[Route('GET', '/users/{id}', name: 'admin.users.show')]
public function show($id) { }
}
// Group attribute (prefix + middleware + route name prefix in one)
#[Group(prefix: '/api', middleware: ['auth'], as: 'api.')]
class ApiController extends Controller
{
#[Route('GET', '/data')]
public function data() { }
}
List discovered routes:
php artisan gale:routes
php artisan gale:routes --json
All frontend features require an Alpine.js context (x-data or x-init).
The $action magic handles all HTTP requests. It defaults to POST with automatic CSRF injection -- the most common pattern for server actions.
<div x-data="{ count: 0 }" x-sync>
<!-- Default: POST with CSRF -->
<button @click="$action('/increment')">+1</button>
<!-- Method shorthands -->
<button @click="$action.get('/api/data')">GET</button>
<button @click="$action.post('/api/save')">POST</button>
<button @click="$action.put('/api/replace')">PUT</button>
<button @click="$action.patch('/api/update')">PATCH</button>
<button @click="$action.delete('/api/remove')">DELETE</button>
</div>
CSRF tokens are automatically injected for all non-GET methods. No manual token handling required.
<button @click="$action('/save', {
include: ['user', 'settings'],
exclude: ['tempData'],
headers: { 'X-Custom': 'value' },
sse: true,
retryInterval: 1000,
retryMaxCount: 10,
requestCancellation: true,
debounce: 300,
throttle: 500,
onProgress: (percent) => console.log(percent)
})">Save</button>
| Option | Type | Default | Description |
|---|---|---|---|
method |
string | 'POST' |
HTTP method |
include |
string[] | -- | Only send these state keys |
exclude |
string[] | -- | Don't send these state keys |
headers |
object | {} |
Additional request headers |
sse |
bool | false |
Force SSE mode for this request |
http |
bool | false |
Force HTTP mode for this request |
retryInterval |
number | 1000 |
Initial retry delay (ms) |
retryScaler |
number | 2 |
Exponential backoff multiplier |
retryMaxWaitMs |
number | 30000 |
Maximum retry delay (ms) |
retryMaxCount |
number | 10 |
Maximum retry attempts |
requestCancellation |
bool | false |
Cancel previous in-flight request |
debounce |
number | -- | Trailing-edge debounce (ms) |
throttle |
number | -- | Leading-edge throttle (ms) |
onProgress |
function | -- | Upload progress callback (0-100) |
The x-sync directive controls which Alpine state properties are sent to the server:
<!-- Send everything -->
<div x-data="{ name: '', email: '', open: false }" x-sync>
<!-- Send specific keys only -->
<div x-data="{ name: '', email: '', open: false }" x-sync="['name', 'email']">
<!-- String syntax shorthand -->
<div x-data="{ name: '', email: '' }" x-sync="name, email">
<!-- No x-sync = send nothing automatically -->
<div x-data="{ name: '', temp: null }">
| x-sync Value | Result |
|---|---|
x-sync (empty) |
Send all state (wildcard) |
x-sync="*" |
Send all state (explicit wildcard) |
x-sync="['a','b']" |
Send only a and b |
x-sync="a, b" |
Send only a and b (string syntax) |
| No directive | Send nothing (use include option if needed) |
The @gale directive adds <meta name="csrf-token">. The $action magic reads this token automatically for all non-GET requests.
// Custom CSRF configuration (rarely needed)
Alpine.gale.configureCsrf({
headerName: 'X-CSRF-TOKEN',
metaName: 'csrf-token',
cookieName: 'XSRF-TOKEN',
});
The $gale magic provides global connection state:
<div x-data>
<div x-show="$gale.loading">Loading...</div>
<div x-show="$gale.retrying">Reconnecting...</div>
<div x-show="$gale.error">
Error: <span x-text="$gale.lastError"></span>
</div>
<span x-text="$gale.activeCount + ' requests active'"></span>
<button @click="$gale.clearErrors()">Clear Errors</button>
</div>
| Property | Type | Description |
|---|---|---|
loading |
bool | Any request in progress |
activeCount |
number | Number of active requests |
retrying |
bool | Currently retrying a request |
retriesFailed |
bool | All retries exhausted |
error |
bool | Has any error |
lastError |
string | Most recent error message |
errors |
array | All error messages |
clearErrors() |
function | Clear all errors |
Track per-element loading state:
<button @click="$action('/save')" :disabled="$fetching()">
<span x-show="!$fetching()">Save</span>
<span x-show="$fetching()">Saving...</span>
</button>
Note: $fetching is a function -- always use $fetching() with parentheses.
Show/hide elements or apply classes during loading:
<div x-loading>Loading...</div>
<div x-loading.remove>Content visible when not loading</div>
<button x-loading.class="opacity-50">Submit</button>
<button x-loading.attr="disabled">Submit</button>
<div x-loading.delay.200ms>Loading (delayed)...</div>
Bind a boolean state variable to loading activity:
<div x-data="{ saving: false }" x-indicator="saving">
<button @click="$action('/save')" :disabled="saving">
<span x-show="!saving">Save</span>
<span x-show="saving">Saving...</span>
</button>
</div>
Enable SPA navigation on links and forms:
<a href="/users" x-navigate>Users</a>
<a href="/users?sort=name" x-navigate.merge>Sort by Name</a>
<a href="/users" x-navigate.replace>Users (replace history)</a>
<!-- Navigation key for partial updates -->
<a href="/users" x-navigate.key.sidebar>Users</a>
<!-- Forms -->
<form action="/search" method="GET" x-navigate>
<input name="q" type="text" />
<button type="submit">Search</button>
</form>
<!-- POST form navigation (PRG pattern) -->
<form action="/submit" method="POST" x-navigate>
<input name="name" type="text" />
<button type="submit">Submit</button>
</form>
| Modifier | Description |
|---|---|
.merge |
Merge query params with current URL |
.replace |
Replace history entry instead of push |
.key.{name} |
Navigation key for targeted updates |
.only.{params} |
Keep only these query params |
.except.{params} |
Remove these query params |
.debounce.{ms} |
Debounce navigation |
.throttle.{ms} |
Throttle navigation |
<button @click="$navigate('/users')">Users</button>
<button @click="$navigate('/users', {
merge: true,
replace: true,
key: 'main-content'
})">Navigate</button>
Exclude specific links from navigation:
<nav x-navigate>
<a href="/dashboard">Dashboard</a>
<a href="/external" x-navigate-skip>External Link</a>
</nav>
Named components that can be targeted from the backend or other components.
<div x-data="{ items: [], total: 0 }" x-component="cart">
<span x-text="total"></span>
</div>
<!-- Access from another component -->
<div x-data>
<span x-show="$components.has('cart')">Cart loaded</span>
<span x-text="$components.state('cart', 'total')"></span>
<button @click="$components.update('cart', { total: 0 })">Clear</button>
<button @click="$invoke('cart', 'recalculate')">Recalculate</button>
</div>
| Method | Description |
|---|---|
get(name) |
Get component Alpine data object |
has(name) |
Check if component exists |
all() |
Get all registered components |
getByTag(tag) |
Get components with tag |
state(name, property) |
Get reactive state value |
update(name, state) |
Merge state into component |
create(name, state) |
Set state (with onlyIfMissing option) |
delete(name, keys) |
Remove state keys |
invoke(name, method, ...args) |
Call method on component |
watch(name, property, callback) |
Watch for changes |
when(name, timeout?) |
Promise resolving when component exists |
onReady(name, callback) |
Callback when component ready |
Combines x-model behavior with automatic state creation and name attributes:
<div x-data="{ email: '', password: '' }">
<input x-name="email" type="email">
<input x-name="password" type="password">
<button @click="$action('/login')">Login</button>
</div>
Supports nested paths, checkboxes, radios, selects, and modifiers:
<input x-name="user.name" type="text">
<input x-name.lazy="search" type="text">
<input x-name.number="quantity" type="text">
<input x-name.trim="username" type="text">
<input x-name.array="tags" type="checkbox" value="alpha">
<div x-data>
<input type="file" name="avatar" x-files />
<div x-show="$file('avatar')">
<p>Name: <span x-text="$file('avatar')?.name"></span></p>
<p>Size: <span x-text="$formatBytes($file('avatar')?.size)"></span></p>
<img :src="https://raw.githubusercontent.com/dancycodes/gale/HEAD/$filePreview('avatar')" />
</div>
<button @click="$action('/upload')">Upload</button>
</div>
| Magic | Description |
|---|---|
$file(name) |
Get single file info |
$files(name) |
Get array of files |
$filePreview(name, index?) |
Get preview URL |
$clearFiles(name?) |
Clear file input(s) |
$formatBytes(size, decimals?) |
Format bytes to human-readable |
$uploading |
Upload in progress |
$uploadProgress |
Progress 0-100 |
Display validation errors and notifications from the server:
<div x-data="{ messages: {} }">
<input x-name="email" type="email">
<span x-message="email" class="text-red-500"></span>
<div x-message="_success" class="text-green-500"></div>
<button @click="$action('/subscribe')">Subscribe</button>
</div>
Array validation with dynamic paths:
<template x-for="(item, index) in items" :key="index">
<div>
<input x-model="items[index].name">
<span x-message="`items.${index}.name`" class="text-red-500"></span>
</div>
</template>
Run expressions at configurable intervals:
<!-- Increment every second -->
<div x-data="{ count: 0 }" x-interval.1s="count++">
<span x-text="count"></span>
</div>
<!-- Poll server every 5 seconds -->
<div x-data="{ status: '' }" x-interval.5s="$action.get('/api/status')">
<span x-text="status"></span>
</div>
<!-- Only run when tab is visible -->
<div x-interval.visible.5s="$action.get('/api/status')">...</div>
<!-- Stop on condition -->
<div x-data="{ done: false, progress: 0 }"
x-interval.1s="progress += 10; done = progress >= 100"
x-interval-stop="done">
Processing...
</div>
<button @click="$action.delete('/item/1')" x-confirm="Are you sure?">
Delete
</button>
After running php artisan vendor:publish --tag=gale-config, edit config/gale.php:
return [
// Default response mode: 'http' (JSON) or 'sse' (Server-Sent Events)
'mode' => env('GALE_MODE', 'http'),
// Intercept dd() and dump() during Gale requests, render in debug panel
'debug' => env('GALE_DEBUG', false),
// Sanitize HTML in gale-patch-elements events (XSS protection)
'sanitize_html' => env('GALE_SANITIZE_HTML', true),
// Allow <script> tags in patched HTML (false = strip scripts)
'allow_scripts' => env('GALE_ALLOW_SCRIPTS', false),
// Inject HTML comment markers for conditional/loop Blade blocks
// Improves morph accuracy; disable in production to reduce payload
'morph_markers' => env('GALE_MORPH_MARKERS', true),
// Content Security Policy nonce: null | 'auto' | '<nonce-string>'
'csp_nonce' => env('GALE_CSP_NONCE', null),
// Security headers added to all Gale responses
'headers' => [
'x_content_type_options' => 'nosniff',
'x_frame_options' => 'SAMEORIGIN',
'cache_control' => 'no-store, no-cache, must-revalidate',
'custom' => [],
],
// Open-redirect prevention
'redirect' => [
'allowed_domains' => [], // e.g. ['payment.stripe.com', '*.myapp.com']
'allow_external' => false,
'log_blocked' => true,
],
// Attribute-based route discovery (opt-in)
'route_discovery' => [
'enabled' => false,
'conventions' => true, // Auto-discover index/show/create/store/edit/update/destroy
'discover_controllers_in_directory' => [
// app_path('Http/Controllers'),
],
'discover_views_in_directory' => [],
'pending_route_transformers' => [
...Dancycodes\Gale\Routing\Config::defaultRouteTransformers(),
],
],
];
Environment variables:
| Variable | Default | Description |
|---|---|---|
GALE_MODE |
http |
Default response mode (http or sse) |
GALE_DEBUG |
false |
Enable debug panel and dd()/dump() interception |
GALE_SANITIZE_HTML |
true |
Sanitize patched HTML for XSS |
GALE_ALLOW_SCRIPTS |
false |
Allow <script> tags in patched HTML |
GALE_MORPH_MARKERS |
true |
Inject Blade morph anchor comments |
GALE_CSP_NONCE |
null |
CSP nonce value |
Gale provides 9 DOM patching modes in three categories:
| Category | Modes | State Handling |
|---|---|---|
| Server-driven | outer (default), inner |
State from server HTML via initTree() |
| Client-preserved | outerMorph, innerMorph |
Existing Alpine state preserved via Alpine.morph() |
| Insertion/Deletion | before, after, prepend, append, remove |
New elements initialized |
Use outer when the server controls all state (forms, server-rendered content).
Use outerMorph when client state must survive the update (counters, toggles, focus).
Backward compatibility: replace() maps to outer(), morph() maps to outerMorph().
HTMX-compatible aliases: outerHTML = outer, innerHTML = inner, beforebegin = before, afterend = after, afterbegin = prepend, beforeend = append, delete = remove.
Enable smooth page transitions via the browser's View Transitions API:
gale()->view('page', $data, ['useViewTransition' => true]);
Global configuration:
Alpine.gale.configure({ viewTransitions: true }); // enabled by default
Falls back gracefully in unsupported browsers.
When using SSE mode, Gale streams these event types:
| Event | Purpose |
|---|---|
gale-patch-state |
Merge state into Alpine component |
gale-patch-elements |
DOM manipulation |
gale-patch-component |
Update named component |
gale-invoke-method |
Call method on component |
gale-patch-state format:
event: gale-patch-state
data: state {"count":1}
data: onlyIfMissing false
gale-patch-elements format:
event: gale-patch-elements
data: selector #content
data: mode outer
data: elements <div id="content">...</div>
gale-patch-component format:
event: gale-patch-component
data: component cart
data: state {"total":42}
gale-invoke-method format:
event: gale-invoke-method
data: component cart
data: method recalculate
data: args [10,20]
When making requests, Alpine Gale serializes the component's x-data based on x-sync:
Serialized: Properties in x-sync, form fields with name attribute, nested objects, arrays.
Not serialized: Functions, DOM elements, circular references, properties starting with _ or $.
<div x-data="{ user: {...}, temp: null }" x-sync="['user']">
<button @click="$action('/save')">Save User</button>
<!-- Only { user: {...} } is sent -->
</div>
Alpine.gale.configure({
defaultMode: 'http', // 'http' | 'sse'
viewTransitions: true, // Enable View Transitions API
foucTimeout: 3000, // Max ms to wait for stylesheets during navigation
navigationIndicator: true, // Show progress bar during navigation
pauseOnHidden: true, // Pause SSE when tab is hidden
pauseOnHiddenDelay: 1000, // Debounce delay before pausing (ms)
settleDuration: 0, // Swap-settle transition delay (ms)
csrfRefresh: 'auto', // CSRF refresh strategy: 'auto' | 'meta' | 'sanctum'
retry: {
maxRetries: 3, // Max retry attempts for network errors
initialDelay: 1000, // Initial retry delay (ms)
backoffMultiplier: 2, // Exponential backoff multiplier
},
redirect: {
allowedDomains: [], // Trusted external redirect domains
allowExternal: false, // Allow all external redirects
logBlocked: true, // Log blocked redirects
},
});
Register callbacks to run before/after DOM morphing. Useful for preserving third-party library state (Chart.js, GSAP, TipTip, Sortable):
const cleanup = Alpine.gale.onMorph({
beforeUpdate(el, toEl) {
// Called before element is updated
// Return false to prevent the update
},
afterUpdate(el) {
// Called after element is updated
myChart.update();
},
beforeRemove(el) {
// Called before element is removed
// Return false to prevent removal
},
afterRemove(el) {
// Cleanup after removal
},
});
// Remove hooks when component is destroyed
cleanup();
| Method | Description |
|---|---|
state($key, $value, $options) |
Set state to merge into component |
patchState($state) |
Set multiple state keys (alias for state(array)) |
forget($keys) |
Remove state keys |
messages($messages) |
Set messages state |
clearMessages() |
Clear messages |
flash($key, $value) |
Flash to session + Alpine _flash state |
debug($label, $data) |
Send debug data to debug panel |
view($view, $data, $options, $web) |
Render and patch Blade view |
fragment($view, $fragment, $data, $options) |
Render named fragment |
fragments($fragments) |
Render multiple fragments |
html($html, $options, $web) |
Patch raw HTML |
outer($selector, $html, $options) |
Replace element (server state) |
inner($selector, $html, $options) |
Replace inner content (server state) |
outerMorph($selector, $html, $options) |
Morph element (preserve state) |
innerMorph($selector, $html, $options) |
Morph children (preserve state) |
append($selector, $html, $options) |
Append HTML |
prepend($selector, $html, $options) |
Prepend HTML |
before($selector, $html, $options) |
Insert before element |
after($selector, $html, $options) |
Insert after element |
remove($selector) |
Remove element |
js($script, $options) |
Execute JavaScript |
dispatch($event, $data, $options) |
Dispatch DOM event |
navigate($url, $key, $options) |
Trigger SPA navigation |
navigateMerge($params, $key) |
Navigate merging query params |
navigateReplace($url, $key) |
Navigate replacing history |
updateQueries($params, $key) |
Update query params in place |
clearQueries($keys) |
Clear query params |
reload() |
Full page reload |
componentState($name, $state, $options) |
Update component state |
componentMethod($name, $method, $args) |
Call component method |
redirect($url) |
Create redirect response |
stream($callback) |
Stream mode (always SSE) |
when($condition, $true, $false) |
Conditional execution |
unless($condition, $callback) |
Inverse conditional |
whenGale($gale, $web) |
Gale request conditional |
whenNotGale($callback) |
Non-Gale conditional |
whenGaleNavigate($key, $callback) |
Navigate conditional |
web($response) |
Set web fallback response |
reset() |
Clear all accumulated events |
| Macro | Description |
|---|---|
isGale() |
Check if request is a Gale request |
state($key, $default) |
Get state from component |
isGaleNavigate($key) |
Check if navigation request |
galeNavigateKey() |
Get navigation key |
galeNavigateKeys() |
Get all navigation keys |
validateState($rules, $messages, $attrs) |
Validate component state |
| Magic | Description |
|---|---|
$action(url, options?) |
POST with auto CSRF (default) |
$action.get(url, options?) |
GET request |
$action.post(url, options?) |
POST with auto CSRF |
$action.put(url, options?) |
PUT with auto CSRF |
$action.patch(url, options?) |
PATCH with auto CSRF |
$action.delete(url, options?) |
DELETE with auto CSRF |
$gale |
Global connection state |
$fetching() |
Element loading state (call as function) |
$navigate(url, options?) |
Programmatic navigation |
$components |
Component registry API |
$invoke(name, method, ...args) |
Invoke component method |
$file(name) |
Get file info |
$files(name) |
Get files array |
$filePreview(name, index?) |
Get preview URL |
$clearFiles(name?) |
Clear files |
$formatBytes(size, decimals?) |
Format bytes |
$uploading |
Upload in progress |
$uploadProgress |
Upload progress 0-100 |
| Directive | Description |
|---|---|
x-sync |
Sync state to server (wildcard or specific keys) |
x-navigate |
Enable SPA navigation |
x-navigate-skip |
Skip navigation handling |
x-component="name" |
Register named component |
x-name="field" |
Form binding with state |
x-files |
File input binding |
x-message="key" |
Display messages |
x-loading |
Loading state display |
x-indicator="var" |
Loading state variable |
x-interval |
Auto-polling / repeating expression |
x-interval-stop="expr" |
Stop polling condition |
x-confirm |
Confirmation dialog |
| Issue | Cause | Solution |
|---|---|---|
| "Multiple instances of Alpine" | Duplicate Alpine.js loaded | Remove existing Alpine, use @gale only |
$action is undefined |
Magic used outside x-data |
Wrap in x-data element |
| CSRF 419 error | Token expired or missing | Verify @gale is in <head> |
| State not updating | Key mismatch | Check x-data property names match server keys |
| Navigation not working | Missing directive | Add x-navigate to links or container |
| Messages not showing | Wrong key | Ensure x-message key matches server message key |
| Counter not updating | Missing x-sync |
Add x-sync to x-data element to send state |
| JSON shown instead of page | Missing web: true |
Add web: true to gale()->view() for page routes |
For in-depth troubleshooting, see Debug & Troubleshooting.
# Package PHP tests
cd packages/dancycodes/gale
vendor/bin/phpunit
# Run only unit tests
vendor/bin/pest --testsuite Unit
# Run only feature tests
vendor/bin/pest --testsuite Feature
# Static analysis
vendor/bin/phpstan analyse
# Code formatting
vendor/bin/pint
# JavaScript tests (from project root)
npm test
Contributions are welcome. To contribute:
vendor/bin/pest && vendor/bin/phpstan analysevendor/bin/pintReport bugs via GitHub Issues.
MIT License. See LICENSE.
Created by DancyCodes -- dancycodes@gmail.com