Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 Custom Login and Registration Tutorial Without a Starter Kit

๐Ÿ› ๏ธ Laravel Tutorials5 min read

Laravel 13 Custom Login and Registration Tutorial Without a Starter Kit

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 Custom Login and Registration Tutorial Without a Starter Kit

To build a custom login and registration system in Laravel 13, create a controller that validates the form, creates the user with a hashed password and calls Auth::login(), plus a login action that uses Auth::attempt() and regenerates the session. Protect the forms with the guest middleware and the private pages with auth. You get full control over the markup and flow without installing a starter kit.

Starter kits are the fastest route for most projects. I still write the flow by hand when a client needs an unusual sign-up form, an existing design system, or simply wants to understand every line that touches authentication. This tutorial covers that case.

Prerequisites

  • Laravel 13.x on PHP 8.3 or newer.
  • A configured database. The default users migration is enough.
  • The default App\Models\User model, which already casts password as hashed.

Step 1: Define the routes

Group the guest-only pages and the authenticated pages separately in routes/web.php:

CSS
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('/register', [RegisterController::class, 'create'])->name('register');
    Route::post('/register', [RegisterController::class, 'store']);

    Route::get('/login', [LoginController::class, 'create'])->name('login');
    Route::post('/login', [LoginController::class, 'store']);
});

Route::middleware('auth')->group(function () {
    Route::view('/dashboard', 'dashboard')->name('dashboard');
    Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
});

Naming the login route login matters: the auth middleware redirects unauthenticated users to the route with that name by default. You can change the target with $middleware->redirectGuestsTo('/login') in bootstrap/app.php.

Step 2: Write the registration controller

JavaScript
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;

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

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ]);

        // The User model's "hashed" cast hashes the password automatically.
        $user = User::create($data);

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

        return redirect()->route('dashboard');
    }
}

The confirmed rule expects a second field called password_confirmation. Password::defaults() lets you define one password policy for the whole app, for example in AppServiceProvider::boot().

Step 3: Write the login and logout actions

Laravel's starter kits throttle login attempts for you. When writing the flow yourself, add throttling with the RateLimiter facade, keyed by email and IP:

JavaScript
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

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

    public function store(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        $key = Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());

        if (RateLimiter::tooManyAttempts($key, 5)) {
            throw ValidationException::withMessages([
                'email' => 'Too many attempts. Try again in '.RateLimiter::availableIn($key).' seconds.',
            ]);
        }

        if (! Auth::attempt($credentials, $request->boolean('remember'))) {
            RateLimiter::hit($key, 60);

            throw ValidationException::withMessages([
                'email' => 'These credentials do not match our records.',
            ]);
        }

        RateLimiter::clear($key);
        $request->session()->regenerate();

        return redirect()->intended(route('dashboard'));
    }

    public function destroy(Request $request): RedirectResponse
    {
        Auth::logout();

        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect('/');
    }
}

Three details carry the security weight here. session()->regenerate() after login prevents session fixation. redirect()->intended() sends the user back to the page they originally requested. On logout, invalidating the session and regenerating the CSRF token makes sure the old session cannot be reused.

Step 4: Create the Blade forms

Here is a minimal resources/views/auth/login.blade.php. The registration form follows the same pattern with name, email, password and password_confirmation fields:

HTML / XML
<form method="POST" action="{{ route('login') }}">
    @csrf

    <label for="email">Email</label>
    <input id="email" name="email" type="email" value="{{ old('email') }}" required autofocus>
    @error('email') <p class="error">{{ $message }}</p> @enderror

    <label for="password">Password</label>
    <input id="password" name="password" type="password" required>

    <label><input type="checkbox" name="remember"> Remember me</label>

    <button type="submit">Log in</button>
</form>

The logout link must be a POST form with @csrf, not a plain GET link, because the route is defined as POST.

How do I show the logged-in user and protect pages?

Inside any view or controller, auth()->user() or $request->user() returns the current User model, or null for guests. In Blade, the @auth and @guest directives switch navigation links between "Log in" and "Log out". Every route that shows private data belongs inside the auth middleware group from Step 1; a guest who requests one is sent to the login page, and after logging in redirect()->intended() returns them to where they started. For role-based rules on top of that, use gates or policies rather than checks scattered through templates.

Common mistakes

  • Hashing twice. If the model already casts password as hashed, calling Hash::make() as well is harmless because Laravel detects an already-hashed value, but it is redundant. Pick one approach.
  • Skipping session regeneration. Always regenerate after login and invalidate on logout.
  • Revealing which emails exist. Use one generic error message for wrong email and wrong password.
  • No throttling. A login form without rate limiting invites credential stuffing.
  • Missing remember_token column. The default users migration has it; custom tables need it for "remember me".

How to verify it works

Feature tests cover the whole flow quickly. With Pest:

JavaScript
<?php

use App\Models\User;

it('registers a user and logs them in', function () {
    $this->post('/register', [
        'name' => 'Test User',
        'email' => 'test@example.com',
        'password' => 'a-Long-passw0rd!',
        'password_confirmation' => 'a-Long-passw0rd!',
    ])->assertRedirect('/dashboard');

    $this->assertAuthenticated();
});

it('rejects a wrong password', function () {
    $user = User::factory()->create();

    $this->post('/login', ['email' => $user->email, 'password' => 'wrong'])
        ->assertSessionHasErrors('email');

    $this->assertGuest();
});

Run php artisan test, then try the forms in the browser and check that /dashboard redirects to /login when you are logged out.

FAQ

Should I use a starter kit instead?

For most new projects, yes. Starter kits include email verification, password reset and two-factor options. Write your own flow when you need full control or a very specific UI.

How do I log users in with a username instead of email?

Validate a username field and pass ['username' => ..., 'password' => ...] to Auth::attempt(). Any column other than password is used as a where condition.

How do I only allow active users to log in?

Add the condition to the credentials array, for example [...$credentials, 'active' => 1].

References

Article Index & Topic Tags
LaravelAuthenticationLoginRegistration

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.