Skip to content

Signals

Signals are the foundation of reactivity in Hyper applications. They're reactive variables that automatically update your user interface when their values change, eliminating the need for manual DOM manipulation. Understanding signals is essential to building effective Hyper applications.

What Are Signals? (Datastar)

A signal is a reactive variable tracked by Datastar. When a signal's value changes, Datastar automatically updates every element that displays or depends on that signal. Think of signals as your application's reactive state—the single source of truth that drives your interface.

blade
<div data-signals="{count: 0}">
    <!-- Signal 'count' is created with initial value 0 -->
    <p data-text="$count"></p>
    <!-- This paragraph automatically updates when count changes -->
</div>

The $ prefix tells Datastar you're referencing a signal, not a regular JavaScript variable. When you write $count, Datastar watches that signal and updates any element using it whenever its value changes.

Creating Signals

There are several ways to create signals, depending on your needs.

Using data-signals (Datastar)

The data-signals attribute creates signals with initial values:

blade
<div data-signals="{
    username: 'John',
    age: 25,
    isActive: true,
    tags: ['developer', 'designer']
}">
    <!-- Signals are now available throughout the page -->
</div>

You can create multiple signals at once, with any JSON-compatible value: strings, numbers, booleans, arrays, or objects.

Using @signals (Hyper)

The @signals directive creates signals from PHP data, which is more natural in Laravel. It provides several syntax options for different use cases.

Basic Array Syntax

Pass an associative array to create signals:

blade
<div @signals(['username' => $user->name, 'role' => $user->role])>
    <!-- Signals created from PHP variables -->
</div>

Variable Syntax

Use PHP variables directly with special naming conventions:

blade
<div @signals($username, $_editing, $userId_)>
    <!-- Creates signals based on variable names -->
</div>

Variable naming rules:

  • $variable → Creates regular signal variable
  • $_variable → Creates local signal _variable (not sent to server)
  • $variable_ → Creates locked signal variable_ (protected from tampering)

Example:

blade
<?php
$username = 'John';
$_showPassword = false;
$userId_ = auth()->id();
?>

<div @signals($username, $_showPassword, $userId_)>
    <!-- Creates: username, _showPassword, userId_ -->
</div>

Spread Syntax

Spread PHP arrays or objects to create multiple signals at once:

blade
<div @signals(...$user)>
    <!-- If $user = ['name' => 'John', 'email' => 'john@example.com'] -->
    <!-- Creates signals: name, email -->
</div>

You can spread Laravel models and collections:

blade
<div @signals(...$contact)>
    <!-- If $contact is an Eloquent model -->
    <!-- All model attributes become signals -->
</div>

Spread with type prefixes:

blade
<div @signals(...$user_, ...$_ui)>
    <!-- Spread $user_ as locked signals: userId_, role_, etc. -->
    <!-- Spread $_ui as local signals: _theme, _collapsed, etc. -->
</div>

Mixing Syntaxes

Combine different approaches in one directive:

blade
<div @signals(
    ['name' => $user->name, 'email' => $user->email],
    $_editing,
    ...$permissions
)>
    <!-- Combines arrays, variables, and spread syntax -->
</div>

Automatic Type Conversion

The @signals directive automatically converts Laravel types:

blade
<div @signals($user)>
    <!-- Eloquent Model → Converted via toArray() -->
</div>

<div @signals($contacts)>
    <!-- Collection → Converted via toArray() -->
</div>

<div @signals($results)>
    <!-- LengthAwarePaginator → Converted via toArray() -->
</div>

<div @signals($custom)>
    <!-- JsonSerializable → Converted via jsonSerialize() -->
    <!-- Arrayable → Converted via toArray() -->
</div>

Benefits of @signals:

  • Pass PHP variables directly without manual JSON encoding
  • Automatic type conversion for Laravel objects
  • Automatic escaping and proper JSON encoding
  • Support for variable syntax, spreads, and arrays
  • Required for creating locked signals (security feature)

Automatic Creation via data-bind (Datastar)

When you bind an input to a signal that doesn't exist, Datastar creates it automatically:

blade
<input data-bind="email" placeholder="Enter email" />
<!-- Signal 'email' is created automatically if it doesn't exist -->

The signal takes its initial value from the input's current value. This is convenient for forms where you don't need to declare signals upfront.

Using data-computed (Datastar)

Computed signals derive their values from other signals and update automatically:

blade
<div data-signals="{firstName: 'John', lastName: 'Doe'}">
    <div data-computed:names="$firstName + ' ' + $lastName"></div>
    <p data-text="$names"></p>
    <!-- Displays: John Doe -->
</div>

Computed signals are read-only. When firstName or lastName changes, names updates automatically.

Signal Scope (Datastar)

Signals are globally scoped once created. A signal created anywhere on the page is accessible everywhere on that page:

blade
<!-- Create signal in header -->
<header data-signals="{siteName: 'My App'}">
    <h1 data-text="$siteName"></h1>
</header>

<!-- Use same signal in footer -->
<footer>
    <p>© 2025 <span data-text="$siteName"></span></p>
</footer>

Both locations display "My App" and update together if siteName changes. This eliminates prop drilling and makes state sharing simple.

Signal Naming

Datastar uses dot notation for nested signal paths:

blade
<div data-signals="{user: {name: 'John', age: 30}}">
    <p data-text="$user.name"></p>  <!-- Access nested property -->
    <button data-on:click="$user.age = $user.age + 1">Birthday</button>
</div>

You can organize related signals into namespaces using objects, making your state structure clearer.

Reading Signals

In the Frontend (Datastar)

Use the $ prefix to read signals in any Datastar expression:

blade
<div data-signals="{price: 100, quantity: 2}">
    <!-- Display signal value -->
    <p data-text="$price"></p>

    <!-- Use in computation -->
    <p data-text="'Total: $' + ($price * $quantity)"></p>

    <!-- Use in conditional -->
    <div data-show="$quantity > 0">Items in cart</div>
</div>

Signals work in any Datastar expression: data-text, data-show, data-class, data-on:click, etc.

In the Backend (Hyper)

Use the signals() helper to read signals sent from the frontend:

php
public function updateCart()
{
    // Get a specific signal with optional default
    $quantity = signals('quantity', 1);

    // Get a nested signal
    $userName = signals('user.name');

    // Check if a signal exists
    if (signals()->has('coupon')) {
        $discount = signals('coupon');
    }

    // Get all signals
    $allSignals = signals()->all();

    // Get as Collection for Laravel methods
    $signals = signals()->collect();

    // Get only specific signals
    $subset = signals()->only(['name', 'email', 'age']);
}

The signals() helper works like Laravel's request() helper, providing familiar methods for accessing data sent from the frontend.

Updating Signals

From the Frontend (Datastar)

Update signals directly in Datastar expressions:

blade
<div data-signals="{count: 0}">
    <button data-on:click="$count = $count + 1">Increment</button>
    <button data-on:click="$count = $count - 1">Decrement</button>
    <button data-on:click="$count = 0">Reset</button>

    <span data-text="$count"></span>
</div>

You can use any JavaScript-like expression for updates:

blade
<button data-on:click="$count++">Increment</button>
<button data-on:click="$count += 5">Add 5</button>

From the Backend (Hyper)

Use hyper()->signals() to update signals from your Laravel controller:

php
public function increment()
{
    $count = signals('count', 0);
    $newCount = $count + 1;

    return hyper()->signals(['count' => $newCount]);
}

You can update multiple signals at once:

php
return hyper()->signals([
    'count' => 5,
    'message' => 'Updated!',
    'errors' => []
]);

Creating signals on update:

If you update a signal that doesn't exist, it will be created automatically:

php
// Even if 'newSignal' doesn't exist on the frontend yet
return hyper()->signals(['newSignal' => 'Hello']);
// Datastar will create it automatically

Alternative single signal syntax:

php
// Update a single signal using key/value syntax
return hyper()->signals('count', 5);

Deleting Signals

Signals can be deleted from both the frontend and backend.

From the Frontend (Datastar)

Datastar deletes signals by setting their value to null:

blade
<div data-signals="{user: 'John', temp: 'data'}">
    <!-- Delete the temp signal -->
    <button data-on:click="$temp = null">Remove Temp</button>
    <div data-text="$temp"></div>

When a signal is set to null, Datastar removes it from the signal store entirely.

From the Backend (Hyper)

Use hyper()->forget() to delete signals from your controller:

php
// Forget a single signal
return hyper()->forget('temp');

// Forget multiple signals
return hyper()->forget(['temp', 'cache', 'draft']);

// Forget all signals
return hyper()->forget();

The forget() method is chainable with other operations:

php
return hyper()
    ->forget(['temp', 'draft'])
    ->signals(['message' => 'Cleaned up!']);

How it works:

Internally, forget() sets the specified signals to null, which tells Datastar to remove them:

php
// These are equivalent
return hyper()->forget('temp');
return hyper()->signals(['temp' => null]);

Forgetting locked signals:

When you forget a locked signal, Hyper also clears it from the session storage:

php
return hyper()->forget('userId_');  // Also clears from session

Signal Types

Hyper and Datastar support three types of signals, each with different transmission behavior.

Regular Signals (Datastar)

Regular signals are sent to the server with every request:

blade
<div data-signals="{username: 'John', email: 'john@example.com'}">
    <button data-on:click="@postx('/update')">Save</button>
</div>

When the button is clicked, both username and email are automatically included in the request payload. Your Laravel controller receives them via signals('username') and signals('email').

Use regular signals for:

  • Form data that needs validation
  • User input that affects server logic
  • State the server needs to process

Local Signals (Datastar)

Local signals (prefixed with _) stay in the browser and are never sent to the server:

blade
<div data-signals="{
    username: 'John',
    _showPassword: false,
    _accordionOpen: false
}">
    <!-- username is sent to server, _showPassword and _accordionOpen stay local -->
</div>

When you make a request, only username is sent. The local signals remain in the browser, reducing payload size and network traffic.

Use local signals for:

  • UI state (dropdowns open/closed, tabs selected)
  • Temporary display toggles
  • Client-side-only interactions
  • Loading and error states for specific UI elements

Important: Even though local signals aren't sent to the server, the server can still update them using hyper()->signals(['_showPassword' => true]). The underscore prefix only controls frontend-to-server transmission, not server-to-frontend updates.

Locked Signals (Hyper Extension)

Locked signals (suffixed with _) are sent to the server and protected from tampering. They can only be created using the @signals directive, not data-signals:

blade
<div @signals(['userId_' => auth()->id(), 'role_' => auth()->user()->role])>
    <!-- These signals are protected from client-side modification -->
</div>

When you create locked signals with @signals, Hyper:

  1. Stores their values securely in the encrypted session
  2. Validates on every request that values haven't been tampered with
  3. Throws HyperSignalTamperedException if tampering is detected

Trying to create locked signals with data-signals won't provide this protection:

blade
<!-- ❌ This doesn't create protected locked signals -->
<div data-signals="{userId_: 123}">

The security mechanism requires PHP-side processing through the @signals directive to store and validate the values.

Use locked signals for:

  • User IDs and authentication data
  • Permission levels and roles
  • Pricing information
  • Any sensitive data that should only change through authorized server actions

Learn more in Advanced: Locked Signals.

Validation

Hyper integrates Laravel's validation system with signals, making validation feel natural.

Validating Signals

Use signals()->validate() exactly like request()->validate():

php
public function store()
{
    $validated = signals()->validate([
        'title' => 'required|string|max:255',
        'email' => 'required|email',
        'age' => 'integer|min:18'
    ]);

    // Use validated data...
}

You can also provide custom messages and attribute names:

php
$validated = signals()->validate(
    ['email' => 'required|email|unique:users'],
    ['email.unique' => 'This :attribute is already taken.'], 
    ['email' => 'email address'] 
);

If validation fails, Hyper automatically:

  1. Creates an errors signal with Laravel's error structure
  2. Returns it to the frontend
  3. Displays errors using data-error attributes

Displaying Errors

Use the data-error attribute (Hyper extension) to display validation errors:

blade
<div data-signals="{title: '', errors: []}">
    <input data-bind="title" />
    <div data-error="title"></div>
</div>

When validation fails, data-error="title" automatically shows the first error message for the title field. When validation passes or you clear errors, the element becomes hidden.

Learn more in Essentials: Validation.

Common Patterns

Form State Management

blade
<form @signals(['name' => '', 'email' => '', 'errors' => []])
      data-on:submit__prevent="@postx('/contacts')">

    <input data-bind="name" />
    <div data-error="name"></div>

    <input data-bind="email" type="email" />
    <div data-error="email"></div>

    <button type="submit">Save</button>
</form>
php
public function store()
{
    $validated = signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email'
    ]);

    Contact::create($validated);

    return hyper()->signals([
        'name' => '',
        'email' => '',
    ]);
}

Toggle States

blade
<div data-signals="{_showDetails: false}">
    <button data-on:click="$_showDetails = !$_showDetails">
        <span data-text="$_showDetails ? 'Hide' : 'Show'"></span> Details
    </button>

    <div data-show="$_showDetails">
        <p>Detailed information here...</p>
    </div>
</div>

Counters and Metrics

blade
<div @signals(['downloads' => 0, 'limit' => 100])>
    <p data-text="$downloads + ' / ' + $limit + ' downloads'"></p>

    <button data-on:click="@postx('/download')"
            data-attr:disabled="$downloads >= $limit">
        Download
    </button>
</div>
php
public function download()
{
    $downloads = signals('downloads', 0);

    if ($downloads >= signals('limit', 100)) {
        return hyper()->signals(['error' => 'Download limit reached']);
    }

    // Process download...

    return hyper()->signals(['downloads' => $downloads + 1]);
}

Master-Detail State

blade
<div @signals(['selectedId' => null])>
    <ul>
        @foreach($users as $user)
            <li data-on:click="$selectedId = {{ $user->id }}"
                data-class:active="$selectedId === {{ $user->id }}">
                {{ $user->name }}
            </li>
        @endforeach
    </ul>

    <div data-show="$selectedId !== null">
        <button data-on:click="@getx('/users/' + $selectedId)">
            Load Details
        </button>
    </div>
</div>

Signal Lifecycle

Understanding how signals flow through a request helps you use them effectively:

  1. Initialization: Signals are created on page load via data-signals or @signals
  2. Frontend Updates: User interactions modify signal values through Datastar expressions
  3. Request: When an HTTP action fires (e.g., @postx), Datastar sends all regular signals (not local _ signals) to the server
  4. Server Processing: Your Laravel controller reads signals using signals(), processes logic, runs validation
  5. Response: Server returns updated signals via hyper()->signals()
  6. Frontend Update: Datastar receives signal updates and automatically updates all dependent elements

This cycle creates a reactive loop where the server and frontend stay synchronized, with the server remaining the source of truth for business logic and validation.

Debugging Signals

View Current Signal Values

Add a debug panel to see all current signals:

blade
<div data-signals="{count: 0, user: 'John'}">
    <!-- Your app content -->

    @if(config('app.debug'))
        <pre data-text="JSON.stringify($$signals, null, 2)"></pre>
    @endif
</div>

The $$signals (double dollar) accesses Datastar's internal signal store.

Log Signal Changes

Use data-effect to log when signals change:

blade
<div data-signals="{count: 0}">
    <div data-effect="console.log('Count changed:', $count)"></div>
    <button data-on:click="$count++">Increment</button>
</div>

Every time count changes, the console logs the new value.

Server-Side Inspection

Log signals received by your controller:

php
public function update()
{
    \Log::info('Received signals:', signals()->all());

    // Your logic...
}

What's Next?

Now that you understand signals, explore related topics: