Skip to content

Authentication

This recipe demonstrates building a login and registration system with reactive validation feedback, loading states, and secure session-based authentication using Laravel's built-in Auth features.

What We're Building

A complete authentication flow with:

  • Login form with email and password
  • Registration form with validation
  • Real-time validation feedback
  • Password visibility toggle
  • Loading states during submission
  • "Remember me" functionality

Login Form

Blade Template

Create resources/views/auth/login.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Sign In</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
    <div class="w-full max-w-md">
        <div class="bg-white rounded-lg shadow-lg p-8">
            <h2 class="text-2xl font-bold mb-6 text-gray-900">Sign In</h2>

            <form @signals([
                'email' => old('email', ''),
                'password' => '',
                'remember' => false,
                '_showPassword' => false,
                '_submitting' => false,
                'errors' => []
            ])
                  data-on:submit__prevent="$_submitting = true; @postx('/login')">

                <!-- Email Field -->
                <div class="mb-4">
                    <label for="email" class="block text-sm font-medium text-gray-700 mb-1">
                        Email Address
                    </label>
                    <input
                        type="email"
                        id="email"
                        data-bind="email"
                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                        placeholder="you@example.com"
                        data-attr:disabled="$_submitting"
                        required
                    />
                    <div data-error="email" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Password Field -->
                <div class="mb-4">
                    <label for="password" class="block text-sm font-medium text-gray-700 mb-1">
                        Password
                    </label>
                    <div class="relative">
                        <input
                            id="password"
                            data-bind="password"
                            data-attr:type="$_showPassword ? 'text' : 'password'"
                            class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                            placeholder="Enter your password"
                            data-attr:disabled="$_submitting"
                            required
                        />
                        <button
                            type="button"
                            data-on:click="$_showPassword = !$_showPassword"
                            class="absolute right-3 top-2.5 text-gray-500 hover:text-gray-700">
                            <span data-text="$_showPassword ? '🙈' : '👁️'"></span>
                        </button>
                    </div>
                    <div data-error="password" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Remember Me & Forgot Password -->
                <div class="flex items-center justify-between mb-6">
                    <label class="flex items-center">
                        <input
                            type="checkbox"
                            data-bind="remember"
                            class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
                            data-attr:disabled="$_submitting"
                        />
                        <span class="ml-2 text-sm text-gray-700">Remember me</span>
                    </label>
                    <a href="/password/reset" class="text-sm text-blue-600 hover:text-blue-800">
                        Forgot password?
                    </a>
                </div>

                <!-- Submit Button -->
                <button
                    type="submit"
                    data-attr:disabled="$_submitting"
                    data-class="{
                    'opacity-50': $_submitting,
                    'cursor-not-allowed': $_submitting
                    }"
                    class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
                    <span data-show="!$_submitting">Sign In</span>
                    <span data-show="$_submitting">Signing in...</span>
                </button>

                <!-- Registration Link -->
                <p class="mt-4 text-center text-sm text-gray-600">
                    Don't have an account?
                    <a href="/register" class="text-blue-600 hover:text-blue-800 font-medium">
                        Sign up
                    </a>
                </p>
            </form>
        </div>
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/Auth/LoginController.php:

php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    public function showLoginForm()
    {
        return view('auth.login');
    }

    public function login()
    {
        $validated = signals()->validate([
            'email' => 'required|email',
            'password' => 'required|string',
        ]);

        $remember = signals('remember', false);

        if (Auth::attempt($validated, $remember)) {
            request()->session()->regenerate();

            return redirect('/dashboard');
        }

        return hyper()->signals([
            'errors' => [
                'email' => ['These credentials do not match our records.']
            ],
            'password' => '',
            '_submitting' => false
        ]);
    }

    public function logout()
    {
        Auth::logout();
        request()->session()->invalidate();
        request()->session()->regenerateToken();

        return redirect('/login');
    }
}

Routes

Add to routes/web.php:

php
use App\Http\Controllers\Auth\LoginController;


Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');

How It Works

Signal Initialization

The @signals directive (Hyper) initializes the form state:

blade
@signals([
    'email' => old('email', ''),
    'password' => '',
    'remember' => false,
    '_showPassword' => false,  // Local signal (Datastar)
    '_submitting' => false,    // Local signal (Datastar)
    'errors' => []
])

Local signals (prefixed with _) are a Datastar feature. They stay in the browser and aren't sent to the server, making them perfect for UI-only state like password visibility and loading indicators.

Two-Way Binding

data-bind (Datastar) creates reactive two-way binding between inputs and signals:

blade
<input data-bind="email" />

When the user types, the email signal updates automatically. When the server updates the signal, the input value changes automatically.

Password Visibility Toggle

Datastar's data-attr:type dynamically changes the input type:

blade
<input data-attr:type="$_showPassword ? 'text' : 'password'" />
<button data-on:click="$_showPassword = !$_showPassword">
    <span data-text="$_showPassword ? '🙈' : '👁️'"></span>
</button>

The button toggles the _showPassword signal, which changes the input between password and text types.

Form Submission

The form uses Datastar's data-on:submit with the __prevent modifier to stop default submission:

blade
data-on:submit__prevent="$_submitting = true; @postx('/login')"

Breakdown:

  • data-on:submit - Datastar listens for the submit event
  • __prevent - Datastar modifier that prevents default browser submission
  • $_submitting = true - Sets loading state
  • @postx('/login') - Hyper's CSRF-protected POST action

Validation Errors

Hyper's data-error attribute automatically displays validation errors from Laravel:

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

When signals()->validate() fails in the controller, Hyper creates an errors signal with Laravel's error structure. The data-error attribute shows the first error for that field.

Loading States

Multiple Datastar attributes work together to show loading states:

blade
<button
    data-attr:disabled="$_submitting"
    data-class="{
    'opacity-50': $_submitting,
    'cursor-not-allowed': $_submitting
    }"
    <span data-show="!$_submitting">Sign In</span>
    <span data-show="$_submitting">Signing in...</span>
</button>
  • data-attr:disabled (Datastar) - Conditionally adds the disabled attribute
  • data-class:* (Datastar) - Conditionally applies CSS classes
  • data-show (Datastar) - Toggles visibility with display: none

Successful Authentication

After successful login, Laravel's Auth facade handles session creation:

php
if (Auth::attempt($validated, $remember)) {
    request()->session()->regenerate();
    return redirect('/dashboard');
}

redirect()->hyperNavigate() (Hyper) performs a full page redirect using JavaScript's window.location, properly handling Laravel's session regeneration for security.

Registration Form

Blade Template

Create resources/views/auth/register.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Create Account</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center py-12">
    <div class="w-full max-w-md">
        <div class="bg-white rounded-lg shadow-lg p-8">
            <h2 class="text-2xl font-bold mb-6 text-gray-900">Create Account</h2>

            <form @signals([
                'name' => old('name', ''),
                'email' => old('email', ''),
                'password' => '',
                'password_confirmation' => '',
                '_showPassword' => false,
                '_submitting' => false,
                'errors' => []
            ])
                  data-on:submit__prevent="$_submitting = true; @postx('/register')">

                <!-- Name Field -->
                <div class="mb-4">
                    <label for="name" class="block text-sm font-medium text-gray-700 mb-1">
                        Full Name
                    </label>
                    <input
                        type="text"
                        id="name"
                        data-bind="name"
                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                        placeholder="John Doe"
                        data-attr:disabled="$_submitting"
                        required
                    />
                    <div data-error="name" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Email Field -->
                <div class="mb-4">
                    <label for="email" class="block text-sm font-medium text-gray-700 mb-1">
                        Email Address
                    </label>
                    <input
                        type="email"
                        id="email"
                        data-bind="email"
                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                        placeholder="you@example.com"
                        data-attr:disabled="$_submitting"
                        required
                    />
                    <div data-error="email" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Password Field -->
                <div class="mb-4">
                    <label for="password" class="block text-sm font-medium text-gray-700 mb-1">
                        Password
                    </label>
                    <div class="relative">
                        <input
                            id="password"
                            data-bind="password"
                            data-attr:type="$_showPassword ? 'text' : 'password'"
                            class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                            placeholder="At least 8 characters"
                            data-attr:disabled="$_submitting"
                            required
                        />
                        <button
                            type="button"
                            data-on:click="$_showPassword = !$_showPassword"
                            class="absolute right-3 top-2.5 text-gray-500 hover:text-gray-700">
                            <span data-text="$_showPassword ? '🙈' : '👁️'"></span>
                        </button>
                    </div>
                    <div data-error="password" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Password Confirmation -->
                <div class="mb-6">
                    <label for="password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">
                        Confirm Password
                    </label>
                    <input
                        id="password_confirmation"
                        data-bind="password_confirmation"
                        data-attr:type="$_showPassword ? 'text' : 'password'"
                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                        placeholder="Re-enter your password"
                        data-attr:disabled="$_submitting"
                        required
                    />
                    <div data-error="password_confirmation" class="mt-1 text-sm text-red-600"></div>
                </div>

                <!-- Submit Button -->
                <button
                    type="submit"
                    data-attr:disabled="$_submitting"
                    data-class:opacity-50="$_submitting"
                    data-class:cursor-not-allowed="$_submitting"
                    class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
                    <span data-show="!$_submitting">Create Account</span>
                    <span data-show="$_submitting">Creating account...</span>
                </button>

                <!-- Login Link -->
                <p class="mt-4 text-center text-sm text-gray-600">
                    Already have an account?
                    <a href="/login" class="text-blue-600 hover:text-blue-800 font-medium">
                        Sign in
                    </a>
                </p>
            </form>
        </div>
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/Auth/RegisterController.php:

php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class RegisterController extends Controller
{
    public function showRegistrationForm()
    {
        return view('auth.register');
    }

    public function register()
    {
        $validated = signals()->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:8|confirmed',
        ], [
            'email.unique' => 'This email address is already registered.',
            'password.confirmed' => 'The password confirmation does not match.',
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        Auth::login($user);
        request()->session()->regenerate();

        return redirect('/dashboard');
    }
}

Routes

Add to routes/web.php:

php
use App\Http\Controllers\Auth\RegisterController;


Route::get('/register', [RegisterController::class, 'showRegistrationForm'])->name('register');
Route::post('/register', [RegisterController::class, 'register']);

Key Takeaways

1. Clear Attribution:

  • Datastar provides: data-bind, data-on:*, data-attr:*, data-class:*, data-show, data-text and local signals (underscore prefix)
  • Hyper provides: @signals directive, data-error attribute, @postx action, and signals()->validate() helper

2. Local Signals for UI State: Using _showPassword and _submitting (Datastar's local signals) reduces server payload since they stay in the browser.

3. Standard Laravel Authentication: Hyper works seamlessly with Laravel's Auth facade and session-based authentication. No special adaptations needed.

4. Validation Integration:signals()->validate() (Hyper) uses Laravel's standard validation rules and automatically creates the errors signal for frontend display.

Enhancements

Real-Time Email Availability Check

Add instant feedback when checking if an email is taken:

blade
<input
    data-bind="email"
    data-on:input__debounce.500ms="@get('/check-email?email=' + encodeURIComponent($email))"
/>

In the web.php:

php
Route::get('/check-email', function () {
    $email = signals()->get('email'); // Get the email from ?email=...

    
    // Check if any user already has this email
    $exists = User::where('email', $email)->exists();
    
    return hyper()->signals([
        'errors' => $exists
            ? []
            : ['This email is already taken.']
    ]);
});

The __debounce.500ms modifier (Datastar) waits 500ms after the user stops typing before making the request. You could check the error at the level of the network tab.

Password Strength Indicator

Use Datastar's data-computed:* for reactive password strength:

blade
 <div class="flex flex-col space-y-1">
    <input 
        type="password" 
        data-bind="password" 
        placeholder="Enter your password"
        class="border p-1 rounded"
    />

    <div data-computed:strength="
        $password.length === 0 ? 'none' :
        $password.length < 8 ? 'weak' :
        $password.length < 12 ? 'medium' : 'strong'
    ">
        <div class="mt-2 text-sm">
            <span data-show="$strength === 'weak'" class="text-red-600">
                Weak password
            </span>
            <span data-show="$strength === 'medium'" class="text-yellow-600">
                Medium strength
            </span>
            <span data-show="$strength === 'strong'" class="text-green-600">
                Strong password
            </span>
        </div>
    </div>
</div>

The data-computed:strength (Datastar) creates a computed signal that automatically updates when the password changes.