dancycodes / gale
Laravel-native reactive frontends using Alpine Gale. Build dynamic UIs with Blade templates and Server-Sent Events.
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0
- symfony/finder: ^5.4.2|^6.0|^7.0
Requires (Dev)
- fakerphp/faker: ^1.23
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^4.0
- phpunit/phpunit: ^11.0 || ^12.0
README
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:
- Laravel Gale -- The PHP backend package (
dancycodes/gale) - Alpine Gale -- The Alpine.js frontend plugin (bundled with Laravel Gale)
Full documentation: docs/README.md | Getting Started | Backend API | Frontend API
Table of Contents
- Requirements
- Quick Start
- Installation
- How It Works
- Mode Configuration
- Backend: Laravel Gale
- Frontend: Alpine Gale
- Configuration Reference
- Advanced Topics
- API Reference
- Troubleshooting
- Testing
- Contributing
- License
Requirements
- PHP 8.2 or higher
- Laravel 11 or 12
- Alpine.js 3.x (bundled -- no separate install needed)
No Node.js or npm required for basic usage. @gale serves the pre-built JS bundle from public/vendor/gale/.
Quick Start
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.
Installation
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:
- CSRF meta tag
- Alpine.js (v3) with the Morph plugin
- The Alpine Gale plugin
- Debug panel (when
APP_DEBUG=true)
Existing Alpine.js Projects
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.
Using Additional Alpine Plugins
Gale exposes window.Alpine, so other plugins work normally:
<head> @gale <script> document.addEventListener('alpine:init', () => { Alpine.plugin(yourPlugin); }); </script> </head>
Optional: Publish Configuration
php artisan vendor:publish --tag=gale-config
How It Works
Dual-Mode Architecture
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.
Request/Response Flow
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 |
+----------------------------------------------------+
RFC 7386 JSON Merge Patch
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" } } |
- Values merge: Sent values replace existing values
- Null deletes: Sending
nullremoves the property - Deep merge: Nested objects merge recursively
Mode Configuration
HTTP vs SSE Comparison
| 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 |
Choosing a Mode
Use HTTP mode (default) when:
- Building forms, CRUD operations, or standard interactions
- Deploying to serverless, CDN-fronted, or shared hosting
- You want the simplest possible setup
- Response times are fast (< 1 second)
Use SSE mode when:
- You need real-time progress updates (file processing, imports)
- Building live dashboards or chat interfaces
- Streaming AI-generated content
- Operations take more than a few seconds
Configuring the Default Mode
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'), // ... ];
Per-Request Mode Override
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); });
Backend: Laravel Gale
The gale() Helper
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.
State Management
state()
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]);
patchState()
Alias for state() when passing an array -- preferred for explicit multi-key patches:
gale()->patchState(['count' => 1, 'updated' => true]);
forget()
Remove state properties (sends null per RFC 7386):
gale()->forget('tempData'); gale()->forget(['tempData', 'cache', 'draft']);
messages()
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!']);
clearMessages()
Clear all messages:
gale()->clearMessages();
flash()
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>
DOM Manipulation
view()
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);
html()
Patch raw HTML into the DOM:
gale()->html('<div id="content">New content</div>'); gale()->html('<li>New item</li>', [ 'selector' => '#list', 'mode' => 'append', ]);
DOM Convenience Methods
// 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 |
Blade Fragments
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], ]);
Redirects
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 |
Navigation
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();
Events and JavaScript
dispatch()
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>
js()
Execute JavaScript in the browser:
gale()->js('console.log("Hello from server")'); gale()->js('myApp.showNotification("Saved!")');
debug()
Send debug data to the Gale debug panel (dev mode only):
gale()->debug('payload', $request->all()); gale()->debug(['user' => $user, 'state' => $state]);
Component Targeting
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]);
Streaming Mode (SSE)
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"]); });
Request Macros
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', ]);
Blade Directives
@gale
Include the JavaScript bundle and CSRF meta tag:
<head> @gale </head>
Accepts optional options:
@gale(['nonce' => config('gale.csp_nonce')])
@fragment / @endfragment
Define extractable fragments:
@fragment('header') <header>{{ $title }}</header> @endfragment
@ifgale / @else / @endifgale
Conditional rendering based on request type:
@ifgale <div id="content">{{ $content }}</div> @else @include('layouts.app') @endifgale
Validation
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!']); }
Conditional Execution
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')));
Route Discovery
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
Frontend: Alpine Gale
All frontend features require an Alpine.js context (x-data or x-init).
The $action Magic
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.
Request Options
<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) |
State Synchronization (x-sync)
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) |
CSRF Protection
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', });
Global State ($gale)
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 |
Element State ($fetching)
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.
Loading Directives
x-loading
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>
x-indicator
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>
Navigation
x-navigate Directive
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 |
$navigate Magic
<button @click="$navigate('/users')">Users</button> <button @click="$navigate('/users', { merge: true, replace: true, key: 'main-content' })">Navigate</button>
x-navigate-skip
Exclude specific links from navigation:
<nav x-navigate> <a href="/dashboard">Dashboard</a> <a href="/external" x-navigate-skip>External Link</a> </nav>
Component Registry
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 |
Form Binding (x-name)
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">
File Uploads
<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="$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 |
Message Display
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>
Polling (x-interval)
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>
Confirmation Dialogs
<button @click="$action.delete('/item/1')" x-confirm="Are you sure?"> Delete </button>
Configuration Reference
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 |
Advanced Topics
DOM Patching Modes
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.
View Transitions API
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.
SSE Protocol Specification
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]
State Serialization
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>
Global Configuration API
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 }, });
Morph Lifecycle Hooks
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();
API Reference
GaleResponse Methods
| 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 |
Request Macros
| 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 |
Frontend Magics
| 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 |
Frontend Directives
| 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 |
Troubleshooting
| 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.
Testing
# 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
Contributing
Contributions are welcome. To contribute:
- Fork the repository and create a feature branch
- Write tests for any new functionality
- Run the full test suite:
vendor/bin/pest && vendor/bin/phpstan analyse - Format code:
vendor/bin/pint - Submit a pull request with a clear description of the change
Report bugs via GitHub Issues.
License
MIT License. See LICENSE.
Credits
Created by DancyCodes -- dancycodes@gmail.com