Web Fallback
Build routes that work with and without JavaScript.
Overview
Not every request to your routes will be a Gale request. Users might:
- Visit a URL directly (bookmarks, shared links)
- Have JavaScript disabled
- Be a search engine crawler
- Use your site before JavaScript loads
Gale's web fallback system lets you handle both Gale and traditional HTTP requests from the same route.
Basic Usage
The web() Method
Use web() to specify what to return for non-Gale requests:
public function show(Post $post)
{
return gale()
->state('post', $post)
->view('posts.detail', compact('post'))
->web(view('posts.detail', compact('post')));
}
public function show(Post $post)
{
return gale()
->state('post', $post)
->view('posts.detail', compact('post'))
->web(view('posts.detail', compact('post')));
}
When a Gale request hits this route, it receives state and DOM updates via SSE. When a regular HTTP request hits it, the user gets the full HTML page.
Shorthand: web Option
For simpler cases, use the web option on view() or html():
return gale()
->state('post', $post)
->view('posts.detail', compact('post'), web: true);
return gale()
->state('post', $post)
->view('posts.detail', compact('post'), web: true);
With web: true, Gale automatically returns the view for non-Gale requests.
How It Works
-
Gale checks the request type
Using theX-Galeheader sent by Alpine Gale -
For Gale requests:
Returns SSE stream with state/DOM events -
For regular requests:
Returns the web fallback response (typically a full HTML view)
Progressive Enhancement
Practical Patterns
List Page with Filtering
public function index(Request $request)
{
$products = Product::query()
->when(request()->state('category'), fn($q, $cat) => $q->where('category', $cat))
->when(request()->state('search'), fn($q, $s) => $q->search($s))
->paginate(12);
return gale()
->state('products', $products->items())
->state('pagination', [
'current' => $products->currentPage(),
'last' => $products->lastPage(),
])
->fragment('products.index', 'product-grid', compact('products'), [
'selector' => '#product-grid',
])
->web(view('products.index', compact('products')));
}
public function index(Request $request)
{
$products = Product::query()
->when(request()->state('category'), fn($q, $cat) => $q->where('category', $cat))
->when(request()->state('search'), fn($q, $s) => $q->search($s))
->paginate(12);
return gale()
->state('products', $products->items())
->state('pagination', [
'current' => $products->currentPage(),
'last' => $products->lastPage(),
])
->fragment('products.index', 'product-grid', compact('products'), [
'selector' => '#product-grid',
])
->web(view('products.index', compact('products')));
}
Form Submission
public function store(Request $request)
{
$validated = request()->validateState([
'name' => 'required|string|max:255',
'email' => 'required|email',
]);
$contact = Contact::create($validated);
// Gale request - update state and show success message
if (request()->isGale()) {
return gale()
->state('submitted', true)
->state('name', '')
->state('email', '')
->clearMessages();
}
// Regular form POST - redirect with flash message
return redirect()->route('contact.index')
->with('success', 'Thank you for your message!');
}
public function store(Request $request)
{
$validated = request()->validateState([
'name' => 'required|string|max:255',
'email' => 'required|email',
]);
$contact = Contact::create($validated);
// Gale request - update state and show success message
if (request()->isGale()) {
return gale()
->state('submitted', true)
->state('name', '')
->state('email', '')
->clearMessages();
}
// Regular form POST - redirect with flash message
return redirect()->route('contact.index')
->with('success', 'Thank you for your message!');
}
Detail Page with Related Data
public function show(Product $product)
{
$related = Product::where('category', $product->category)
->where('id', '!=', $product->id)
->limit(4)
->get();
return gale()
->state([
'product' => $product,
'related' => $related,
'quantity' => 1,
])
->view('products.show', compact('product', 'related'), web: true);
}
public function show(Product $product)
{
$related = Product::where('category', $product->category)
->where('id', '!=', $product->id)
->limit(4)
->get();
return gale()
->state([
'product' => $product,
'related' => $related,
'quantity' => 1,
])
->view('products.show', compact('product', 'related'), web: true);
}
SEO Considerations
Web fallbacks are essential for SEO because search engine crawlers typically don't execute JavaScript.
Full HTML for Crawlers
Search engines receive complete, rendered HTML pages.
Proper URLs
Navigation updates browser history with shareable URLs.
Meta Tags
Set unique titles and descriptions in your Blade layouts.
<!-- In your layout -->
<head>
<title>@yield('title', 'Default Title') - My App</title>
<meta name="description" content="@yield('description', 'Default description')">
<meta property="og:title" content="@yield('title')">
<meta property="og:description" content="@yield('description')">
</head>
<!-- In your layout -->
<head>
<title>Web Fallback - My App</title>
<meta name="description" content="Build hybrid routes that work with both Gale requests and traditional page loads.">
<meta property="og:title" content="Web Fallback">
<meta property="og:description" content="Build hybrid routes that work with both Gale requests and traditional page loads.">
</head>
Testing Both Modes
Test your routes handle both request types:
// Test regular HTTP request
public function test_shows_product_page()
{
$product = Product::factory()->create();
$response = $this->get("/products/{$product->id}");
$response->assertOk();
$response->assertViewIs('products.show');
}
// Test Gale request
public function test_returns_gale_response()
{
$product = Product::factory()->create();
$response = $this->withHeaders(['X-Gale' => 'true'])
->get("/products/{$product->id}");
$response->assertOk();
$response->assertHeader('Content-Type', 'text/event-stream');
}
// Test regular HTTP request
public function test_shows_product_page()
{
$product = Product::factory()->create();
$response = $this->get("/products/{$product->id}");
$response->assertOk();
$response->assertViewIs('products.show');
}
// Test Gale request
public function test_returns_gale_response()
{
$product = Product::factory()->create();
$response = $this->withHeaders(['X-Gale' => 'true'])
->get("/products/{$product->id}");
$response->assertOk();
$response->assertHeader('Content-Type', 'text/event-stream');
}
Quick Reference
| Method | Description |
|---|---|
gale()->web($response) |
Set fallback response for non-Gale requests |
->view($view, $data, web: true) |
Auto-fallback with the same view |
->html($html, $opts, web: true) |
Auto-fallback with HTML |
request()->isGale() |
Check if current request is Gale |