Your First Hyper Application
Let's build a counter application from scratch. This hands-on tutorial will teach you Hyper's fundamentals by progressively adding reactivity to a simple app.
What We'll Build
A counter with these features:
- Increment and decrement buttons
- Display current count
- Server-side logic and validation
- Real-time UI updates without page reloads
- Status indicators based on count value
By the end, you'll understand how Hyper makes Laravel applications reactive.
Step 1: The Starting Point - Pure HTML
Let's begin with the simplest possible counter—just HTML with no functionality.
Create a route in routes/web.php:
use Illuminate\Support\Facades\Route;
Route::get('/counter', function () {
return view('counter');
});Create the view in resources/views/counter.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
</head>
<body>
<div>
<button>-</button>
<span>0</span>
<button>+</button>
</div>
</body>
</html>Try it: Visit /counter in your browser. You'll see three elements: a minus button, the number 0, and a plus button. The buttons don't do anything yet—they're just static HTML.
Our Goal: Make clicking "+" increase the number by one, and clicking "-" decrease it by one.
Step 2: Installing Hyper
Now let's add the tools we need.
Install the package (if you haven't already):
composer require dancycodes/hyper
php artisan vendor:publish --tag=hyper-assetsUpdate your counter view to include Hyper:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
{{-- Include Hyper (Datastar + Hyper enhancements) --}}
@hyper
</head>
<body>
<div>
<button>-</button>
<span>0</span>
<button>+</button>
</div>
</body>
</html>What @hyper Does
If you view the page source, you'll see @hyper generated:
- CSRF Meta Tag:
<meta name="csrf-token" content="..."> - Script Tag:
<script type="module" src="/vendor/hyper/js/hyper.js"></script>
This loads Datastar (the reactive framework) plus Hyper's Laravel extensions.
Your counter still looks the same and buttons don't work. That's expected—we've just installed the tools. Now let's use them.
Step 3: Adding Frontend Reactivity
Let's make the counter work entirely in the frontend first. This teaches fundamental concepts before involving the server.
Understanding Signals (Datastar Concept):
A "signal" is a reactive variable that the UI watches. When the signal's value changes, any UI element bound to it automatically updates. Think of it like a spreadsheet—when you change a cell, formulas referencing it recalculate automatically.
Update your view to add reactivity:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
@hyper
</head>
<body>
<div data-signals="{count: 0}">
<button data-on:click="$count = $count - 1">-</button>
<span data-text="$count"></span>
<button data-on:click="$count = $count + 1">+</button>
</div>
</body>
</html>Try it: Click the buttons! The counter now works.
What's happening:
data-signals="{count: 0}"- Creates a reactive signal namedcountwith initial value0(Datastar)data-on:click="$count = $count + 1"- When button is clicked, increasecount(Datastar)data-text="$count"- Display the current value ofcount(Datastar)$count- The$prefix accesses the signal value (Datastar)
Datastar Reactivity
All the reactivity here is powered by Datastar. The data-* attributes, signals, and automatic UI updates are Datastar features. Hyper hasn't done anything yet—we're using pure Datastar.
Key Insight: The signal count is reactive. When you update it ($count = $count + 1), Datastar automatically updates every element displaying that signal (data-text="$count").
Step 4: Moving to the Server
The frontend counter works, but as Laravel developers, we often want logic on the server for business rules, validation, persistence, and testing. Let's move our increment/decrement logic to Laravel controllers.
Create a controller:
php artisan make:controller CounterControllerAdd the show and increment methods:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CounterController extends Controller
{
public function show()
{
return view('counter');
}
public function increment()
{
// First, let's verify our route is being called
dd("increment called - route is working!");
}
}Update routes to use the controller:
use App\Http\Controllers\CounterController;
Route::get('/counter', [CounterController::class, 'show']);
Route::post('/counter/increment', [CounterController::class, 'increment']);Try to call the Laravel route from your frontend:
<div data-signals="{count: 0}">
<button data-on:click="$count = $count - 1">-</button>
<span data-text="$count"></span>
{{-- Try to call our Laravel route --}}
<button data-on:click="@post('/counter/increment')">+</button>
</div>Try it: Click the + button and open your browser's Network tab. You'll see the request fails with a "419 Page Expired" error.
Common Mistake #1: CSRF Token Missing
This is the most common error for new Hyper users! Laravel protects all POST requests with CSRF tokens to prevent attacks. Datastar's @post() action doesn't include the CSRF token by default, so Laravel rejects it with a 419 error.
Solution: Always use Hyper's CSRF-protected actions for mutating operations: @postx(), @putx(), @patchx(), @deletex()
Solution: Use Hyper's Enhanced Actions
This is where Hyper's Laravel-specific enhancements come in. Replace @post with @postx:
<div data-signals="{count: 0}">
<button data-on:click="$count = $count - 1">-</button>
<span data-text="$count"></span>
{{-- Hyper's @postx automatically includes CSRF token --}}
<button data-on:click="@postx('/counter/increment')">+</button>
</div>Try the + button again. Now it works! Check the Network tab—the request succeeds and includes the X-CSRF-TOKEN header automatically. You should see Laravel's dd() output.
Hyper vs Pure Datastar
- Pure Datastar:
@post(),@get(),@put(),@patch(),@delete()- Standard HTTP actions - Hyper Enhanced:
@postx(),@putx(),@patchx(),@deletex()- Same actions with automatic Laravel CSRF token injection
This is your first taste of how Hyper extends Datastar with Laravel-specific conveniences.
Step 5: Understanding Signal Transmission
When you click the + button, notice something important in the Network tab request payload: Datastar automatically sends ALL current signal values to your Laravel controller.
You'll see something like:
{
"count": 0
}This is automatic. Datastar sends every signal with every request. Your Laravel controllers always have access to the complete frontend state.
Let's use this in our controller:
public function increment()
{
// Read the signal sent from the frontend (Hyper helper)
$currentCount = signals('count', 0);
// Increment it
$newCount = $currentCount + 1;
// For now, just verify we're receiving it correctly
dd("Current count from frontend: " . $currentCount);
}Try clicking + and you'll see the current count value in the dump output.
The signals() Helper (Hyper)
signals() is a Hyper helper function that reads signals sent from the frontend. It works just like Laravel's request() helper:
signals('count')- Get the count signalsignals('count', 0)- Get count with default valuesignals()->all()- Get all signals.
Step 6: Returning Signal Updates
Now let's make the server actually update the counter. We need to send the new count back to the frontend.
Update the increment method:
public function increment()
{
$currentCount = signals('count', 0);
$newCount = $currentCount + 1;
// Return updated signal to frontend (Hyper helper)
return hyper()->signals(['count' => $newCount]);
}Try clicking + and watch the Network tab. You'll see a Server-Sent Event response:
event: datastar-patch-signals
data: signals {"count":1}Clicking + sends the request, the server increments the count, and the UI updates automatically!
The hyper() Helper (Hyper)
hyper() is Hyper's fluent response builder, similar to Laravel's response() helper. It provides methods for:
- Updating signals:
hyper()->signals() - Rendering views:
hyper()->view() - DOM manipulation:
hyper()->html(),remove(),append(), etc. - Streaming:
hyper()->stream()
Step 7: Adding the Decrement Action
Let's add the decrement functionality with server validation.
Add the decrement method to your controller:
public function decrement()
{
$currentCount = signals('count', 0);
$newCount = $currentCount - 1;
return hyper()->signals(['count' => $newCount]);
}Add the route:
Route::post('/counter/decrement', [CounterController::class, 'decrement']);Update the frontend:
<div data-signals="{count: 0}">
<button data-on:click="@postx('/counter/decrement')">-</button>
<span data-text="$count"></span>
<button data-on:click="@postx('/counter/increment')">+</button>
</div>Try it: Both buttons now work, with all logic on the server!
Step 8: Adding Validation
Let's prevent the count from going negative using Laravel's validation.
Update the decrement method:
public function decrement()
{
// Validate using Hyper's validation (extends Laravel validation)
$validated = signals()->validate([
'count' => 'required|integer|gt:0'
], [
'count.gt' => 'The count cannot be negative!'
]);
$currentCount = signals('count', 0);
$newCount = $currentCount - 1;
return hyper()->signals(['count' => $newCount]);
}Add an errors signal and error display:
<div data-signals="{count: 0, errors: []}">
<button data-on:click="@postx('/counter/decrement')">-</button>
<span data-text="$count"></span>
<button data-on:click="@postx('/counter/increment')">+</button>
{{-- Display validation errors (Hyper's data-error attribute) --}}
<div data-error="count" style="color: red;"></div>
</div>Try it:
- Click - until count reaches 0
- Try clicking - again
- You'll see "The count cannot be negative!" appear
Hyper's Validation Integration
signals()->validate()- Works exactly like Laravel's$request->validate()- Validation errors automatically populate the
errorssignal data-error="count"- Hyper's attribute that displays errors for thecountfield- When validation passes, errors are automatically cleared
How it works:
signals()->validate()validates the signals- If validation fails, it throws an exception and sends errors to frontend
- Hyper automatically updates the
errorssignal data-error="count"displays the error for thecountfield- If validation passes,
errorsis cleared
Step 9: Local Signals
Sometimes you want signals that stay in the frontend and aren't sent to the server. Let's add a signal that tracks decrement attempts.
Add a local signal (underscore prefix):
<div data-signals="{count: 0, errors: [], _attempts: 0}">
{{-- Increment attempts when clicking decrement --}}
<button data-on:click="@postx('/counter/decrement'); $_attempts = $_attempts + 1">-</button>
<span data-text="$count"></span>
<button data-on:click="@postx('/counter/increment')">+</button>
<div data-error="count" style="color: red;"></div>
{{-- Show attempts locally (not sent to server) --}}
<div>Decrement attempts: <span data-text="$_attempts"></span></div>
</div>Try it: Click the - button multiple times. Watch the Network tab—you'll see that only count and errors are sent to the server, while _attempts stays in the browser and updates reactively.
Signal Types (Datastar)
- Regular signals (
count) - Sent to server with every request - Local signals (
_attempts) - Stay in browser, never sent to server (underscore prefix) - Locked signals (
userId_) - Sent to server, validated for tampering (underscore suffix, Hyper feature)
Though local signals aren't sent to the server, they can still be updated from the server using hyper()->signals().
Step 10: Server-Initialized Signals
Instead of manually writing JSON for signals, use Hyper's @signals directive for a more Laravel-native approach.
Update your controller's show method:
public function show()
{
$initialData = [
'count' => 0,
'max_value' => 100
];
return view('counter', compact('initialData'));
}Use the @signals directive in your view:
<div @signals(...$initialData, ['_attempts' => 0])>
<button data-on:click="@postx('/counter/decrement'); $_attempts = $_attempts + 1">-</button>
<span data-text="$count"></span>
<button data-on:click="@postx('/counter/increment')">+</button>
<div data-error="count" style="color: red;"></div>
{{-- Show the maximum allowed value from the server --}}
<div>Maximum: <span data-text="$max_value"></span></div>
<div>Decrement attempts: <span data-text="$_attempts"></span></div>
</div>Understanding the Spread Operator
...spreads the$initialDataarray into individual signals:count,max_value- Without spread, you'd get an
initialDatasignal instead of individual signals - You can mix server data (spread from array) with frontend-only signals using array syntax
Benefits of @signals (Hyper)
- Type-safe signal initialization from PHP variables
- Automatic JSON encoding with proper escaping
- Clear separation between backend data and frontend presentation
- Easy to pass complex data structures (arrays, objects, models, collections)
Step 11: DOM Manipulation with Views
So far we've updated signal values. Now let's learn to update HTML elements themselves. We'll add a status message that changes based on the count value.
Add a status area to your view:
<div @signals(...$initialData, ['_attempts' => 0])>
<button data-on:click="@postx('/counter/decrement'); $_attempts = $_attempts + 1">-</button>
<span data-text="$count"></span>
<button data-on:click="@postx('/counter/increment')">+</button>
<div data-error="count" style="color: red;"></div>
{{-- Status area to be updated from server --}}
<div id="status" style="padding: 10px; margin-top: 20px;">
<p>Status will appear here</p>
</div>
</div>Create a status view in resources/views/counter-status.blade.php:
<div id="status" style="padding: 10px; margin-top: 20px; {{ $statusStyle }}">
<strong>{{ $statusText }}</strong>
</div>Update the increment method to return HTML:
public function increment()
{
$currentCount = signals('count', 0);
$newCount = $currentCount + 1;
// Determine status based on count
if ($newCount <= 3) {
$statusText = "Low ({$newCount})";
$statusStyle = "background: #e3f2fd; color: #1976d2;";
} elseif ($newCount <= 7) {
$statusText = "Medium ({$newCount})";
$statusStyle = "background: #fff3e0; color: #f57f17;";
} else {
$statusText = "High ({$newCount})";
$statusStyle = "background: #ffebee; color: #d32f2f;";
}
return hyper()
->signals(['count' => $newCount, 'errors' => []])
->view('counter-status', compact('statusText', 'statusStyle'));
}Try it: Click the increment button and watch the status area update with different colors based on the count!
Understanding hyper()->view() (Hyper)
- Renders a Blade view with provided data
- By default, targets elements with matching IDs (the
#statusdiv) - The entire element is replaced with the rendered view
- Chain with
->signals()to update both signals and HTML - Uses full power of Blade templating for dynamic HTML
Step 12: Using Fragments for Code Reuse
Fragments let you define reusable sections within Blade views without creating separate files. The key insight is that fragments are about code reuse - you can render a fragment to update any part of your page using selector options.
Update your main view to include fragments:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
@hyper
</head>
<body>
<div @signals(['count' => 0])>
<h2>Counter with Fragments</h2>
{{-- Button controls --}}
<div id="counter-buttons">
@fragment('button-controls')
<button data-on:click="@postx('/counter/decrement')"
{{ $count <= 0 ? 'disabled style="opacity: 0.5;"' : '' }}>
- <span data-text="$count > 0 ? 'Active' : 'Disabled'"></span>
</button>
<span data-text="$count" style="margin: 0 20px; font-size: 24px;"></span>
<button data-on:click="@postx('/counter/increment')"
{{ $count >= 10 ? 'disabled style="opacity: 0.5;"' : '' }}>
+ <span data-text="$count < 10 ? 'Active' : 'Disabled'"></span>
</button>
@endfragment
</div>
</div>
</body>
</html>Update controller to use fragments with proper targeting:
public function show()
{
$count = 0;
return view('counter', compact('count'));
}
public function increment()
{
$currentCount = signals('count', 0);
$newCount = min($currentCount + 1, 10); // Cap at 10
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}
public function decrement()
{
$currentCount = signals('count', 0);
$newCount = max($currentCount - 1, 0);
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}Try it: The buttons update to show Active/Disabled state and get disabled at 0 and 10!
Understanding Fragment Options (Hyper)
selector- Specifies which element to target (e.g.,'#counter-buttons')mode- Determines how to update the element:'inner'- Updates only the inner HTML (preserves the container)'outer'- Replaces the entire element'append','prepend','before','after'- Other Datastar modes
Without options, fragments target by ID. With options, you can target any element and choose the update strategy.
Why Use mode: 'inner'?
Using 'inner' mode preserves the #counter-buttons div while updating its contents. This keeps any styling, attributes, or event listeners on the container element intact.
Understanding hyper()->fragment():
hyper()->fragment('counter', 'button-controls', $data, $options)renders thebutton-controlsfragment from thecounter.blade.phpview- The fragment content between
@fragment('name')and@endfragmentgets processed with the provided data - Fragments are about code reuse - this same fragment could update any element on the page by using different selector options
- The
selectorandmodeoptions give you precise control over where and how the fragment updates the DOM
Key insight: Fragments keep related HTML close to where it's used while still allowing for dynamic updates. You avoid creating separate component files for small, related sections.
Step 13: Route Discovery
Writing routes manually is fine for small applications, but Hyper includes an optional route discovery system that can automatically generate routes based on your controller methods. This is inspired by Spatie's Laravel Route Discovery package but enhanced for Hyper applications.
Why Route Discovery?
As your application grows, maintaining route definitions can become tedious. Route discovery eliminates this by:
- Automatically generating routes based on controller method names
- Using attributes to declare HTTP methods and options
- Following RESTful conventions
- Keeping routing logic close to your controllers
Enable route discovery by publishing the Hyper config:
php artisan vendor:publish --tag=hyper-configEdit config/hyper.php and enable route discovery:
return [
'route_discovery' => [
'enabled' => true,
'discover_controllers_in_directory' => [
app_path('Http/Controllers'),
],
],
];Add route attributes to your controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Dancycodes\Hyper\Routing\Attributes\Route;
#[Route(middleware: 'web')]
class CounterController extends Controller
{
public function show()
{
$count = 0;
return view('counter', compact('count'));
}
#[Route(method: 'post')]
public function increment()
{
$currentCount = signals('count', 0);
$newCount = min($currentCount + 1, 10);
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}
#[Route(method: 'post')]
public function decrement()
{
$currentCount = signals('count', 0);
$newCount = max($currentCount - 1, 0);
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}
}Understanding Route Attributes (Hyper Feature):
#[Route(middleware: 'web')]on the class - All routes from this controller use thewebmiddleware#[Route(method: 'post')]on methods - Declares this method handles POST requests- Without the
methodattribute, route discovery assumes GET (for methods likeshow,index, etc.) - Route discovery automatically generates URLs based on controller and method names
Check your routes:
php artisan route:listYou'll see that Hyper automatically discovered and registered these routes:
GET /counter→CounterController@showPOST /counter/increment→CounterController@incrementPOST /counter/decrement→CounterController@decrement
How route discovery works:
- Scans your controllers for public methods
- Automatically generates RESTful routes based on method names
- Uses the
#[Route]attribute to determine HTTP verbs, middleware, and other options - Maps controller and method names to build URLs
Now you can remove the manual route definitions from routes/web.php. Route discovery handles everything!
Benefits of Route Discovery
- Less Maintenance: No need to manually update routes when adding controller methods
- Convention Over Configuration: Follows predictable patterns
- Co-located Logic: Route attributes live next to the controller methods
Optional Feature
Route discovery is completely optional. You can continue using traditional route definitions in routes/web.php if you prefer. Both approaches work perfectly with Hyper.
What You've Learned
Congratulations! You've built a complete reactive counter application with advanced features. Here's what you now understand:
Datastar Fundamentals:
- ✅ Signals and reactivity
- ✅
data-signals,data-text,data-on:click - ✅ Signal types (regular, local)
- ✅ Automatic UI updates
Hyper's Laravel Integration:
- ✅
@hyperdirective for setup - ✅
@postx()for CSRF-protected requests - ✅
signals()helper to read frontend state - ✅
hyper()helper to build responses - ✅
@signalsdirective for server initialization - ✅
data-errorfor validation display - ✅
hyper()->view()for rendering Blade views - ✅
hyper()->fragment()for code reuse - ✅ Fragment targeting with
selectorandmodeoptions - ✅ Route discovery with
#[Route]attributes
Development Flow:
- ✅ Frontend sends signals with every request
- ✅ Server processes logic and validation
- ✅ Server returns signal updates and/or HTML
- ✅ Frontend updates reactively
Next Steps
Now that you've built your first app, explore:
- Core Concepts - Deep dive into signals and reactivity
- Signals - Complete signal reference
- Forms & Validation - Building complex forms
- Real-Time Features - SSE streaming for live updates
Complete Counter Code
Here's the final working code with all features:
config/hyper.php:
<?php
return [
'route_discovery' => [
'enabled' => true,
'discover_controllers_in_directory' => [
app_path('Http/Controllers'),
],
],
];app/Http/Controllers/CounterController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Dancycodes\Hyper\Routing\Attributes\Route;
#[Route(middleware: 'web')]
class CounterController extends Controller
{
public function show()
{
$count = 0;
return view('counter', compact('count'));
}
#[Route(method: 'post')]
public function increment()
{
$currentCount = signals('count', 0);
$newCount = min($currentCount + 1, 10);
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}
#[Route(method: 'post')]
public function decrement()
{
$currentCount = signals('count', 0);
$newCount = max($currentCount - 1, 0);
return hyper()
->signals(['count' => $newCount])
->fragment('counter', 'button-controls', ['count' => $newCount], [
'selector' => '#counter-buttons',
'mode' => 'inner'
]);
}
}resources/views/counter.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
@hyper
</head>
<body>
<div @signals(['count' => 0, 'errors' => []])>
<h2>Reactive Counter</h2>
<div id="counter-buttons">
@fragment('button-controls')
<button data-on:click="@postx('/counter/decrement')"
{{ $count <= 0 ? 'disabled style="opacity: 0.5;"' : '' }}>
- <span data-text="$count > 0 ? 'Active' : 'Disabled'"></span>
</button>
<span data-text="$count" style="margin: 0 20px; font-size: 24px;"></span>
<button data-on:click="@postx('/counter/increment')"
{{ $count >= 10 ? 'disabled style="opacity: 0.5;"' : '' }}>
+ <span data-text="$count < 10 ? 'Active' : 'Disabled'"></span>
</button>
@endfragment
</div>
</div>
</body>
</html>No routes/web.php needed! Route discovery handles everything automatically.
Alternative: Manual Routes
If you prefer not to use route discovery, you can still define routes manually in routes/web.php:
use App\Http\Controllers\CounterController;
Route::get('/counter', [CounterController::class, 'show']);
Route::post('/counter/increment', [CounterController::class, 'increment']);
Route::post('/counter/decrement', [CounterController::class, 'decrement']);Then remove the #[Route] attributes from your controller. Both approaches work perfectly!
Understanding What You Built
Your counter application demonstrates all the core Hyper concepts:
- Reactive State - The
countsignal updates automatically across the UI - Server Logic - Validation and business rules stay on the server
- CSRF Protection - Automatic via
@postx() - Validation - Laravel validation with automatic error display
- Code Reuse - Fragments avoid duplication
- DOM Control - Precise updates with selector and mode options
- Convention - Route discovery eliminates boilerplate
You now have a solid foundation in Laravel Hyper. You understand how signals flow between frontend and backend, how to validate and update state, and how to structure reactive applications.
Ready to build something amazing? Start with a real project or continue learning with more advanced topics in the documentation!

