Skip to content

Validation

Form validation in Hyper keeps validation logic secure on your Laravel backend while providing immediate feedback to users. Laravel validates the signal data, and errors automatically flow back to the frontend for display.

Validating Form Data

Use the signals()->validate() method to validate form data with Laravel's validation rules:

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

    Contact::create($validated);

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

When validation passes, you receive the validated data. When it fails, Hyper automatically creates an errors signal and sends it to the frontend.

Displaying Errors

The data-error attribute (Hyper) displays validation errors for specific fields:

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

    <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>

    <button type="submit">Save Contact</button>
</form>

How it works:

  1. Initialize the errors signal as an empty array
  2. Place data-error="fieldname" below each input
  3. When validation fails, Hyper creates the errors signal with Laravel's error bag structure
  4. The data-error attribute displays the first error for each field
  5. Errors automatically hide when the errors signal is cleared

Custom Error Messages

Laravel's custom validation messages work exactly as expected:

php
signals()->validate(
    [
        'email' => 'required|email|unique:contacts',
        'password' => 'required|min:8',
    ],
    [
        'email.unique' => 'This email is already registered.',
        'email.email' => 'Please enter a valid email address.',
        'password.min' => 'Your password must be at least :min characters long.',
    ]
);

The custom messages appear in the data-error elements automatically.

Custom Attribute Names

Make field names more readable in error messages:

php
signals()->validate(
    [
        'email' => 'required|email',
        'password' => 'required|min:8',
    ],
    [
        // Custom messages
    ],
    [
        'email' => 'email address',
        'password' => 'password',
    ]
);

Laravel uses these attribute names when generating error messages, so instead of "The email field is required," users see "The email address is required."

Real-Time Validation

Validate fields as users type by triggering validation on input events:

blade
<form @signals(['email' => '', 'errors' => []])>
    <div>
        <label>Email</label>
        <input type="email"
               data-bind="email"
               data-on:input__debounce.500ms="@postx('/validate-email')" />
        <div data-error="email" class="text-red-500"></div>
    </div>
</form>
php
public function validateEmail()
{
    try {
        signals()->validate([
            'email' => 'required|email|unique:users',
        ]);

        // Clear errors if validation passes
        return hyper()->signals(['errors' => []]);
    } catch (\Dancycodes\Hyper\Exceptions\HyperValidationException $e) {
        // Errors automatically sent to frontend
        return hyper();
    }
}

The __debounce.500ms modifier (Datastar) waits 500 milliseconds after the user stops typing before sending the validation request. This prevents excessive server requests while providing responsive feedback.

Validation State Indicators

Show users when validation is happening:

blade
<div @signals(['email' => '', 'validating' => false, 'errors' => []])>
    <div>
        <label>Email</label>
        <input type="email"
               data-bind="email"
               data-on:input__debounce.500ms="$validating = true; @postx('/validate-email')" />

        <span data-show="$validating" class="text-gray-500 text-sm">
            Checking availability...
        </span>

        <div data-error="email" class="text-red-500"></div>
    </div>
</div>
php
public function validateEmail()
{
    try {
        signals()->validate([
            'email' => 'required|email|unique:users',
        ]);

        return hyper()->signals(['errors' => [], 'validating' => false]);
    } catch (\Dancycodes\Hyper\Exceptions\HyperValidationException $e) {
        return hyper()->signals(['validating' => false]);
    }
}

The validating signal shows a loading message while the validation request is in flight, then hides when the response arrives.

Conditional Validation

Apply different validation rules based on the context:

php
public function update(Contact $contact)
{
    $rules = [
        'name' => 'required|string|max:255',
        'phone' => 'nullable|string|max:20',
    ];

    // Only validate email uniqueness if it changed
    if (signals('email') !== $contact->email) {
        $rules['email'] = 'required|email|unique:contacts,email,' . $contact->id;
    } else {
        $rules['email'] = 'required|email';
    }

    $validated = signals()->validate($rules);

    $contact->update($validated);

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

This pattern avoids unnecessary database queries when fields haven't changed.

Array Validation

Validate dynamic form fields using Laravel's array validation syntax:

blade
<div @signals(['phone_numbers' => [''], 'errors' => []])>
    <template data-for="phone, index in $phone_numbers">
        <div>
            <label data-text="'Phone ' + (index + 1)"></label>
            <input type="tel" data-bind="phone_numbers[index]" />

            <button type="button"
                    data-show="$phone_numbers.length > 1"
                    data-on:click="$phone_numbers.splice(index, 1)">
                Remove
            </button>
        </div>
    </template>

    <div data-error="phone_numbers" class="text-red-500"></div>
    <div data-error="phone_numbers.0" class="text-red-500"></div>
    <div data-error="phone_numbers.1" class="text-red-500"></div>
    <div data-error="phone_numbers.2" class="text-red-500"></div>

    <button type="button"
            data-on:click="$phone_numbers.push('')">
        Add Phone Number
    </button>

    <button type="button" data-on:click="@postx('/contacts/store')">
        Save Contact
    </button>
</div>
php
public function store()
{
    $validated = signals()->validate([
        'phone_numbers' => 'required|array|min:1|max:3',
        'phone_numbers.*' => 'required|string|max:20',
    ]);

    // Process phone numbers...

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

Laravel validates both the array itself and each element within it. Use data-error="phone_numbers.0" to show errors for specific array elements.

Form-Level Validation

Sometimes you want to show a general form error rather than field-specific errors:

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

    <!-- Show general form errors -->
    <div data-show="$errors.form" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
        <span data-text="$errors.form ? $errors.form[0] : ''"></span>
    </div>

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

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

    <button type="submit">Sign In</button>
</form>
php
public function login()
{
    $credentials = signals()->only(['username', 'password']);

    if (!Auth::attempt($credentials)) {
        return hyper()->signals([
            'errors' => [
                'form' => ['Invalid username or password.']
            ]
        ]);
    }

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

This pattern works well for authentication, where you don't want to reveal whether the username or password was incorrect.

Preventing Duplicate Submissions

Disable the submit button during validation to prevent double submissions:

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

        <div>
            <label>Email</label>
            <input type="email"
                   data-bind="email"
                   data-attr:disabled="$submitting" />
            <div data-error="email" class="text-red-500"></div>
        </div>

        <button type="button"
                data-on:click="$submitting = true; @postx('/contacts')"
                data-attr:disabled="$submitting">
            <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|unique:contacts',
    ]);

    Contact::create($validated);

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

The form disables all inputs and the submit button while the request is processing. Even if validation fails, remember to reset submitting to false:

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

        Contact::create($validated);

        return hyper()->signals([
            'submitting' => false,
            'name' => '',
            'email' => '',
            'errors' => []
        ]);
    } catch (\Dancycodes\Hyper\Exceptions\HyperValidationException $e) {
        return hyper()->signals(['submitting' => false]);
    }
}

Client-Side Validation Preview

While all security-critical validation must happen on the server, you can provide instant feedback for basic requirements:

blade
<div @signals(['password' => '', 'password_confirmation' => '', 'errors' => []])
     data-computed:passwordsMatch="$password === $password_confirmation && $password.length > 0"
     data-computed:passwordLongEnough="$password.length >= 8">

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

        <div data-show="$password.length > 0 && !$passwordLongEnough"
             class="text-yellow-600 text-sm">
            Password must be at least 8 characters
        </div>

        <div data-error="password" class="text-red-500"></div>
    </div>

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

        <div data-show="$password_confirmation.length > 0 && !$passwordsMatch"
             class="text-yellow-600 text-sm">
            Passwords don't match
        </div>
    </div>

    <button type="button"
            data-on:click="@postx('/register')"
            data-attr:disabled="!$passwordsMatch || !$passwordLongEnough">
        Register
    </button>
</div>

The computed signals provide real-time feedback, but the server still validates everything when the form submits. This pattern improves user experience without compromising security.

Common Validation Patterns

Required Fields with Visual Indicators

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

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

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

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

The required attribute provides browser-level validation, while data-error shows server-side validation results.

Unique Field Validation

blade
<form @signals(['email' => '', 'checking' => false, 'errors' => []])>
    <div>
        <label>Email</label>
        <input type="email"
               data-bind="email"
               data-on:input__debounce.700ms="$checking = true; @postx('/check-email')" />

        <span data-show="$checking" class="text-gray-500 text-sm">
            Checking availability...
        </span>

        <div data-error="email" class="text-red-500"></div>

        <div data-show="!$errors.email && $email.length > 0 && !$checking"
             class="text-green-600 text-sm">
            This email is available
        </div>
    </div>
</form>
php
public function checkEmail()
{
    try {
        signals()->validate([
            'email' => 'required|email|unique:users',
        ]);

        return hyper()->signals(['errors' => [], 'checking' => false]);
    } catch (\Dancycodes\Hyper\Exceptions\HyperValidationException $e) {
        return hyper()->signals(['checking' => false]);
    }
}

Multi-Step Form Validation

blade
<div @signals(['step' => 1, 'name' => '', 'email' => '', 'password' => '', 'errors' => []])>
    <!-- Step 1: Personal Info -->
    <div data-show="$step === 1">
        <h2>Personal Information</h2>

        <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('/validate-step-1')">
            Next
        </button>
    </div>

    <!-- Step 2: Security -->
    <div data-show="$step === 2">
        <h2>Set Password</h2>

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

        <button type="button" data-on:click="$step = 1">
            Back
        </button>

        <button type="button" data-on:click="@postx('/register')">
            Complete Registration
        </button>
    </div>
</div>
php
public function validateStep1()
{
    signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
    ]);

    return hyper()->signals(['step' => 2, 'errors' => []]);
}

public function register()
{
    $validated = signals()->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users',
        'password' => 'required|min:8',
    ]);

    User::create($validated);

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

Validate each step separately to provide immediate feedback and prevent users from proceeding with invalid data.