Skip to content

Building Forms

Forms are the primary way users interact with your application. Hyper combines Datastar's reactive data binding with Laravel's backend validation to create forms that feel responsive while keeping validation logic secure on the server.

Basic Form Structure

A typical Hyper form consists of three parts: signal initialization, input bindings, and an action to submit the data to your Laravel backend.

blade
<form @signals(['name' => '', 'email' => '', 'errors' => []])>
    <div>
        <label>Name</label>
        <input type="text" data-bind="name" />
        <div data-error="name" class="text-red-500"></div>
    </div>

    <div>
        <label>Email</label>
        <input type="email" data-bind="email" />
        <div data-error="email" class="text-red-500"></div>
    </div>

    <button type="button" data-on:click="@postx('/contacts')">
        Save Contact
    </button>
</form>
php
public function store()
{
    $validated = signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:contacts',
    ]);

    Contact::create($validated);

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

What's happening:

  1. @signals (Hyper) initializes the form's signals, including an empty errors array for validation messages
  2. data-bind (Datastar) creates two-way binding between inputs and signals
  3. data-error (Hyper) displays validation errors for each field
  4. @postx (Hyper) submits the form with CSRF protection
  5. The controller validates and returns cleared signals on success

Input Types

Datastar's data-bind attribute works with all standard HTML input types, automatically handling type conversion and value synchronization.

Text Inputs

Text inputs are the simplest form element:

blade
<div @signals(['message' => ''])>
    <input type="text" data-bind="message" placeholder="Enter a message" />
    <p data-text="'You typed: ' + $message"></p>
</div>

Email Inputs

Email inputs work identically to text inputs. The browser provides built-in validation UI for email format:

blade
<div @signals(['email' => ''])>
    <input type="email" data-bind="email" placeholder="[email protected]" />
</div>

Number Inputs

Number inputs automatically convert between strings and numbers. The signal value is a JavaScript number, not a string:

blade
<div @signals(['quantity' => 1])>
    <input type="number" data-bind="quantity" min="1" max="10" />
    <p data-text="'Total: $' + ($quantity * 29.99)"></p>
</div>

Textareas

Textareas work like text inputs but for multi-line content:

blade
<div @signals(['bio' => ''])>
    <textarea data-bind="bio" rows="4" placeholder="Tell us about yourself"></textarea>
    <p data-text="$bio.length + ' characters'"></p>
</div>

Select Dropdowns

Select elements bind to the selected option's value:

blade
<div @signals(['category' => ''])>
    <select data-bind="category">
        <option value="">Select a category</option>
        <option value="work">Work</option>
        <option value="personal">Personal</option>
        <option value="urgent">Urgent</option>
    </select>

    <p data-show="$category !== ''" data-text="'Selected: ' + $category"></p>
</div>

Checkboxes

Checkboxes bind to boolean values for single checkboxes, or arrays for checkbox groups:

Single checkbox:

blade
<div @signals(['agreed' => false])">
    <label>
        <input type="checkbox" data-bind="agreed" />
        I agree to the terms and conditions
    </label>

    <button 
    data-attr="{'disabled': !$agreed}"
    data-class="{'text-gray-500': !$agreed}"
    data-on:click="@postx('/submit')">
        Submit
    </button>
</div>

Checkbox group:

blade
<div @signals(['interests' => []])>
    <label>
        <input type="checkbox" data-bind="interests" value="coding" />
        Coding
    </label>

    <label>
        <input type="checkbox" data-bind="interests" value="design" />
        Design
    </label>

    <label>
        <input type="checkbox" data-bind="interests" value="writing" />
        Writing
    </label>

    <p data-text="'Selected: ' + $interests.join(', ')"></p>
</div>

When multiple checkboxes share the same data-bind name, Datastar automatically manages them as an array.

Radio Buttons

Radio buttons bind to a single value that matches the selected option:

blade
<div @signals(['size' => 'medium'])>
    <label>
        <input type="radio" data-bind="size" value="small" />
        Small
    </label>

    <label>
        <input type="radio" data-bind="size" value="medium" />
        Medium
    </label>

    <label>
        <input type="radio" data-bind="size" value="large" />
        Large
    </label>

    <p data-text="'Selected size: ' + $size"></p>
</div>

Form Submission

Forms in Hyper don't use traditional HTML form submission. Instead, you use Datastar's event handlers with Hyper's CSRF-protected actions.

Using @postx (Hyper)

The @postx action sends a POST request with automatic CSRF token inclusion:

blade
<form @signals(['title' => '', 'content' => ''])>
    <input type="text" data-bind="title" />
    <textarea data-bind="content"></textarea>

    <button type="button" data-on:click="@postx('/posts')">
        Create Post
    </button>
</form>

Why type="button"?

Using type="button" prevents the browser's default form submission. You want Datastar to handle submission via the data-on:click action, not the browser's native form behavior.

Preventing Default Submission

If you prefer to use type="submit", prevent the default form submission event:

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

    <input type="email" data-bind="email" />
    <input type="password" data-bind="password" />

    <button type="submit">Sign In</button>
</form>

The __prevent modifier (Datastar) calls event.preventDefault(), stopping the browser's default form submission.

Other HTTP Methods

Hyper provides CSRF-protected actions for all common HTTP verbs:

blade
<!-- PUT request -->
<button data-on:click="@putx('/posts/' + $postId)">Update</button>

<!-- PATCH request -->
<button data-on:click="@patchx('/posts/' + $postId)">Patch</button>

<!-- DELETE request -->
<button data-on:click="@deletex('/posts/' + $postId)">Delete</button>

Pre-filling Forms

When editing existing records, initialize signals with the model's data.

Using Eloquent Models

The @signals directive can spread Eloquent model attributes directly:

blade
<form @signals(...$contact)>
    <input type="text" data-bind="name" />
    <input type="email" data-bind="email" />
    <input type="tel" data-bind="phone" />

    <button type="button" data-on:click="@postx('/contacts/' + $id)">
        Update Contact
    </button>
</form>

All model attributes become signals automatically. If the contact has name, email, and phone attributes, those signals are created with the model's current values.

Selective Field Pre-filling

Pre-fill specific fields while leaving others empty:

blade
<form @signals([
    'name' => $contact->name,
    'email' => $contact->email,
    'category' => $contact->category,
    'phone' => ''
])>
    <input type="text" data-bind="name" />
    <input type="email" data-bind="email" />
    <select data-bind="category">
        <option value="work">Work</option>
        <option value="personal">Personal</option>
    </select>
    <input type="tel" data-bind="phone" />

    <button type="button" data-on:click="@postx('/contacts/' + {{ $contact->id }})">
        Update
    </button>
</form>

Conditional Pre-filling

Show different forms for create vs. edit modes:

blade
<form @if(isset($contact))
        @signals(...$contact)
      @else
        @signals(['name' => '', 'email' => '', 'phone' => ''])
      @endif>

    <input type="text" data-bind="name" />
    <input type="email" data-bind="email" />
    <input type="tel" data-bind="phone" />

    @if(isset($contact))
        <button type="button" data-on:click="@postx('/contacts/{{ $contact->id }}')">
            Update Contact
        </button>
    @else
        <button type="button" data-on:click="@postx('/contacts')">
            Create Contact
        </button>
    @endif
</form>

Clearing Forms

After successful submission, clear the form by resetting signal values:

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

    Contact::create($validated);

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

The inputs automatically update to show the cleared values because of data-bind's two-way synchronization.

Clearing Specific Fields

Clear only certain fields while preserving others:

php
return hyper()->signals([
    'message' => '', // Clear message
    'category' => $category, // Preserve category
    'errors' => []
]);

Using forget()

The forget() method removes signals entirely rather than setting them to empty values:

php
return hyper()
    ->forget(['name', 'email'])
    ->signals(['errors' => []]);

This is useful when you want signals to return to their initial state or disappear completely from the signal store.

Form State Management

Disabling Buttons During Submission

Show loading states while forms process:

blade
<div @signals(['submitting' => false, 'name' => '', 'email' => ''])>
    <form>
        <input type="text" data-bind="name" data-attr="{'disabled': $submitting}" />
        <input type="email" data-bind="email" data-attr="{'disabled': $submitting}"  />

        <button type="button"
                data-on:click="$submitting = true; @postx('/contacts')">
            <span data-show="!$submitting">Save Contact</span>
            <span data-show="$submitting">Saving...</span>
        </button>
    </form>
</div>
php
public function store()
{
    $validated = signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email',
    ]);

    Contact::create($validated);

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

The form disables inputs and changes button text while processing, then re-enables when the response arrives.

Dirty State Tracking

Track whether the form has been modified:

blade
<div @signals([
    'name' => $contact->name,
    'email' => $contact->email,
    '_originalName' => $contact->name,
    '_originalEmail' => $contact->email
])
     data-computed:dirty="$name !== $_originalName || $email !== $_originalEmail">

    <input type="text" data-bind="name" />
    <input type="email" data-bind="email" />

    <button type="button"
            data-on:click="@postx('/contacts/' + {{ $contact->id }})"
            data-attr:disabled="!$dirty">
        Save Changes
    </button>

    <span data-show="$dirty" class="text-yellow-600">
        You have unsaved changes
    </span>
</div>

The _original signals (local signals) stay in the browser and aren't sent to the server. The computed dirty signal compares current values with originals.

Common Patterns

Multi-Field Forms

blade
<form @signals([
    'name' => '',
    'email' => '',
    'phone' => '',
    'category' => '',
    'notes' => '',
    'errors' => []
])>
    <div>
        <label>Name</label>
        <input type="text" data-bind="name" />
        <div data-error="name" class="text-red-500"></div>
    </div>

    <div>
        <label>Email</label>
        <input type="email" data-bind="email" />
        <div data-error="email" class="text-red-500"></div>
    </div>

    <div>
        <label>Phone</label>
        <input type="tel" data-bind="phone" />
        <div data-error="phone" class="text-red-500"></div>
    </div>

    <div>
        <label>Category</label>
        <select data-bind="category">
            <option value="">Select category</option>
            <option value="work">Work</option>
            <option value="personal">Personal</option>
        </select>
        <div data-error="category" class="text-red-500"></div>
    </div>

    <div>
        <label>Notes</label>
        <textarea data-bind="notes" rows="4"></textarea>
        <div data-error="notes" class="text-red-500"></div>
    </div>

    <button type="button" data-on:click="@postx('/contacts')">
        Create Contact
    </button>
</form>
php
public function store()
{
    $validated = signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:contacts',
        'phone' => 'nullable|string|max:20',
        'category' => 'required|in:work,personal',
        'notes' => 'nullable|string|max:1000',
    ]);

    Contact::create($validated);

    return hyper()
        ->signals([
            'name' => '',
            'email' => '',
            'phone' => '',
            'category' => '',
            'notes' => '',
            'errors' => []
        ])
        ->js("showToast('Contact created successfully', 'success')");
}

Password Visibility Toggle

blade
<div @signals(['password' => '', '_showPassword' => false])>
    <label>Password</label>
    <div class="relative">
        <input data-bind="password"
               data-attr:type="$_showPassword ? 'text' : 'password'"
               placeholder="Enter your password" />

        <button type="button"
                class="absolute right-2 top-2"
                data-on:click="$_showPassword = !$_showPassword">
            <svg data-show="!$_showPassword" class="w-5 h-5">
                <!-- Eye icon (show password) -->
            </svg>
            <svg data-show="$_showPassword" class="w-5 h-5">
                <!-- Eye-slash icon (hide password) -->
            </svg>
        </button>
    </div>
    <div data-error="password" class="text-red-500"></div>
</div>

The _showPassword signal (local signal) stays in the browser and controls whether the input shows the password as text or hides it with dots.

Confirmation Fields

blade
<form @signals(['password' => '', 'password_confirmation' => '', 'errors' => []])>
    <div>
        <label>Password</label>
        <input type="password" data-bind="password" />
        <div data-error="password" class="text-red-500"></div>
    </div>

    <div>
        <label>Confirm Password</label>
        <input type="password" data-bind="password_confirmation" />
    </div>

    <button type="button" data-on:click="@postx('/register')">
        Register
    </button>
</form>
php
public function register()
{
    $validated = signals()->validate([
        'password' => 'required|min:8|confirmed',
    ]);

    // Laravel's 'confirmed' rule checks password_confirmation automatically

    User::create($validated);

    return hyper()->signals(['password' => '', 'password_confirmation' => '', 'errors' => []]);
}

Laravel's confirmed validation rule automatically checks that password matches password_confirmation.

Form with Character Counter

blade
<div @signals(['message' => ''])
     data-computed:remaining="250 - $message.length"
     data-computed:overLimit="$remaining < 0">

    <label>Message</label>
    <textarea data-bind="message" rows="4" maxlength="250"></textarea>

    <p data-class:text-red-500="$overLimit">
        <span data-text="$remaining"></span> characters remaining
    </p>

    <button type="button"
            data-on:click="@postx('/messages')"
            data-attr:disabled="$overLimit || $message.length === 0">
        Send Message
    </button>
</div>

The computed signals (remaining and overLimit) automatically update as the user types, providing real-time feedback.