Back to all articles
#laravel#php#authentication

Manual Auth in Laravel 13 — Register, Login, Forgot Password, and Middleware

Skip the starter kits. Build auth from scratch in Laravel 13 — controllers for register, login, forgot password, reset password, and protecting routes with middleware.

📅 Published: July 1, 2026✍️ Author: Muhammad Amirul Ihsan

Why Go Manual?

Laravel’s starter kits (Breeze, Jetstream) are great. They give you a full auth system in one command. But sometimes you want to understand what’s happening under the hood. Or maybe you have a weird auth flow that doesn’t fit the starter kit molds. Or you’re just stubborn and want to build it yourself (I respect that).

Either way, here’s how to build a complete manual auth system in Laravel 13 — register, login, forgot password, reset password, logout, and the middleware to glue it all together.

We’re writing zero starter kit code. Just routes, controllers, views, and good vibes.


The Setup

Make sure your User model exists and your users table migration has the right columns. The default migration already includes everything you need — name, email, password, and remember_token.

If you’re using Laravel 13’s new attribute style, your User model might look like:

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    use Notifiable;

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

The 'password' => 'hashed' cast means any time you set the password, Laravel will automatically hash it with bcrypt. No more manual Hash::make() calls — that’s been a thing since Laravel 10.


1. Register Controller

We need a controller to show the registration form and handle the POST. Let’s make one:

php artisan make:controller Auth/RegisterController
<?php

namespace App\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class RegisterController
{
    /**
     * Show the registration form.
     */
    public function create(): View
    {
        return view('auth.register');
    }

    /**
     * Handle the registration request.
     */
    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);

        $user = User::create($validated);

        // Log them in immediately after registration
        Auth::login($user);

        // Regenerate the session to prevent session fixation
        $request->session()->regenerate();

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

The create method returns a simple Blade view with name, email, password, and password confirmation fields. The store method validates, creates the user, logs them in, regenerates the session, and redirects.

Session fixation attack: Without $request->session()->regenerate(), a malicious user could set a known session ID before logging in, then hijack the authenticated session. Always regenerate after login.

The intended() redirect sends them back to the page they were trying to visit before hitting the auth wall. If they came straight to /register, the fallback is /dashboard.

The Register View

A minimal resources/views/auth/register.blade.php:

<x-guest-layout>
    <form method="POST" action="{{ route('register') }}">
        @csrf

        <div>
            <label for="name">Name</label>
            <input id="name" name="name" value="{{ old('name') }}" required autofocus />
            @error('name') <p class="text-red-500">{{ $message }}</p> @enderror
        </div>

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

        <div>
            <label for="password">Password</label>
            <input id="password" name="password" type="password" required />
            @error('password') <p class="text-red-500">{{ $message }}</p> @enderror
        </div>

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

        <button type="submit">Register</button>
    </form>
</x-guest-layout>

Register Routes

Add these to routes/web.php (or a separate routes/auth.php):

use App\Http\Controllers\Auth\RegisterController;

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

The guest middleware keeps logged-in users out of the registration page. No point registering twice.


2. Login Controller

php artisan make:controller Auth/LoginController
<?php

namespace App\Http\Controllers\Auth;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class LoginController
{
    /**
     * Show the login form.
     */
    public function create(): View
    {
        return view('auth.login');
    }

    /**
     * Handle the login attempt.
     */
    public function store(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

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

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

        return back()->withErrors([
            'email' => 'These credentials do not match our records.',
        ])->onlyInput('email');
    }

    /**
     * Log the user out.
     */
    public function destroy(Request $request): RedirectResponse
    {
        Auth::logout();

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

        return redirect('/');
    }
}

A few things worth pointing out:

  • Auth::attempt() takes the credentials array, looks up the user by email, verifies the hashed password, and starts a session. Returns true if it worked.
  • Second parameter $request->boolean('remember') enables the “remember me” cookie. When checked, Laravel keeps them logged in indefinitely.
  • back()->withErrors(...)->onlyInput('email') sends them back to the login form with the error message and pre-fills the email field. The password field is cleared (security best practice).
  • logout() does three things: calls Auth::logout() to clear auth state, $request->session()->invalidate() to nuke the session entirely, and $request->session()->regenerateToken() to rotate the CSRF token. This prevents session reuse attacks.

The Login View

<x-guest-layout>
    <form method="POST" action="{{ route('login') }}">
        @csrf

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

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

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

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

        <a href="{{ route('password.request') }}">Forgot your password?</a>
    </form>
</x-guest-layout>

Login Routes

use App\Http\Controllers\Auth\LoginController;

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

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

The login form routes are guest-only. The logout route is auth-only — you can’t log out if you’re not logged in.


3. Forgot Password Controller

The password reset flow has two parts: requesting a link, and actually resetting. Let’s build the “forgot password” half first.

php artisan make:controller Auth/PasswordResetLinkController
<?php

namespace App\Http\Controllers\Auth;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;

class PasswordResetLinkController
{
    /**
     * Show the forgot password form.
     */
    public function create(): View
    {
        return view('auth.forgot-password');
    }

    /**
     * Send the password reset link.
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate(['email' => 'required|email']);

        $status = Password::sendResetLink(
            $request->only('email')
        );

        return $status === Password::ResetLinkSent
            ? back()->with('status', __($status))
            : back()->withErrors(['email' => __($status)]);
    }
}

Here’s what happens under the hood when you call Password::sendResetLink():

  1. Laravel looks up the user by email via your user provider (by default, the users table).
  2. It generates a random token and stores it in the password_reset_tokens table (created by the default migration).
  3. It sends the user an email with a link containing that token. The email uses Laravel’s notification system via the User model’s sendPasswordResetNotification() method.

The Password::sendResetLink() returns a status slug like passwords.sent or passwords.user. The __() helper translates it using your language files.

Pre-req check: Your User model must use Notifiable and implement CanResetPassword. The default model already does both.

The Forgot Password View

<x-guest-layout>
    <p>Forgot your password? No problem. Enter your email and we'll send you a reset link.</p>

    @if (session('status'))
        <p class="text-green-500">{{ session('status') }}</p>
    @endif

    <form method="POST" action="{{ route('password.email') }}">
        @csrf

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

        <button type="submit">Email Password Reset Link</button>
    </form>
</x-guest-layout>

Forgot Password Routes

use App\Http\Controllers\Auth\PasswordResetLinkController;

Route::middleware('guest')->group(function () {
    Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
        ->name('password.request');
    Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
        ->name('password.email');
});

The route names password.request and password.email are what Laravel’s default auth middleware looks for. Don’t rename them unless you also update the middleware redirect config.


4. Reset Password Controller

When the user clicks the link in their email, they land on a page with the token embedded. Time to actually change the password.

php artisan make:controller Auth/NewPasswordController
<?php

namespace App\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\View\View;

class NewPasswordController
{
    /**
     * Show the reset password form.
     */
    public function create(string $token): View
    {
        return view('auth.reset-password', ['token' => $token]);
    }

    /**
     * Handle the password reset.
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'token' => 'required',
            'email' => 'required|email',
            'password' => 'required|min:8|confirmed',
        ]);

        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function (User $user, string $password) {
                $user->forceFill([
                    'password' => Hash::make($password),
                ])->setRememberToken(Str::random(60));

                $user->save();

                event(new PasswordReset($user));
            }
        );

        return $status === Password::PasswordReset
            ? redirect()->route('login')->with('status', __($status))
            : back()->withErrors(['email' => __($status)]);
    }
}

A few details worth understanding:

  • Password::reset() validates the token against the password_reset_tokens table. If the token matches, is not expired, and belongs to the right email — the closure fires.
  • forceFill() bypasses the $fillable guard. Since password and remember_token might not be fillable, this ensures the update goes through.
  • setRememberToken(Str::random(60)) rotates the remember token, invalidating all existing “remember me” sessions. Security best practice after a password change.
  • event(new PasswordReset($user)) fires the PasswordReset event. You can hook listeners into this (send a notification, log the event, etc.).

By default, reset tokens expire after 60 minutes. You can change this in config/auth.php under passwords.users.expire.

The Reset Password View

<x-guest-layout>
    <form method="POST" action="{{ route('password.update') }}">
        @csrf

        <!-- Hidden token field -->
        <input type="hidden" name="token" value="{{ $token }}" />

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

        <div>
            <label for="password">New Password</label>
            <input id="password" name="password" type="password" required />
            @error('password') <p class="text-red-500">{{ $message }}</p> @enderror
        </div>

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

        <button type="submit">Reset Password</button>
    </form>
</x-guest-layout>

Reset Password Routes

use App\Http\Controllers\Auth\NewPasswordController;

Route::middleware('guest')->group(function () {
    Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
        ->name('password.reset');
    Route::post('/reset-password', [NewPasswordController::class, 'store'])
        ->name('password.update');
});

The {token} parameter comes from the email link. Laravel’s password broker validates it against the password_reset_tokens table automatically.

Where to put these routes: I keep them in routes/web.php for simplicity. If you prefer, create routes/auth.php and require it from web.php. Either way works.

Dashboard Route

While we’re here, let’s add the dashboard — the page authenticated users land on:

Route::middleware('auth')->group(function () {
    Route::get('/dashboard', fn () => view('dashboard'))->name('dashboard');
});

5. Middleware — Protecting and Redirecting

Laravel 13 handles middleware configuration in bootstrap/app.php using the ->withMiddleware() callback. Here’s how to set up the redirect behavior:

<?php
// bootstrap/app.php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Where to send unauthenticated users
        $middleware->redirectGuestsTo('/login');

        // Where to send authenticated users (when they hit guest-only pages)
        $middleware->redirectUsersTo('/dashboard');
    })
    ->create();

The auth Middleware

This is the bread-and-butter guard. Attach it to any route that requires authentication:

Route::get('/dashboard', fn () => view('dashboard'))->middleware('auth');

If an unauthenticated user hits this route, they get redirected to /login. After logging in, redirect()->intended() sends them back here.

The guest Middleware

The opposite — prevents authenticated users from seeing login/register pages:

Route::get('/login', ...)->middleware('guest');

If a logged-in user visits /login, they get redirected to /dashboard.

Custom Middleware for Role Checking

Want to restrict routes to admin users? Make a custom middleware:

php artisan make:middleware EnsureUserIsAdmin
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->is_admin) {
            abort(403, 'You are not an admin.');
        }

        return $next($request);
    }
}

Register it as an alias in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
    ]);
})

Then use it:

Route::get('/admin/users', fn () => view('admin.users'))
    ->middleware(['auth', 'admin']);

Combining auth + admin means: first verify they’re logged in, then verify they’re an admin. Order matters.


6. Auth Events You Can Hook Into

Laravel dispatches these events during the auth lifecycle. Great for logging, analytics, or sending welcome emails:

Event When
Illuminate\Auth\Events\Registered After a user registers
Illuminate\Auth\Events\Attempting Before credentials are checked
Illuminate\Auth\Events\Authenticated After successful authentication
Illuminate\Auth\Events\Login After a successful login
Illuminate\Auth\Events\Failed After a failed login attempt
Illuminate\Auth\Events\Logout After logout
Illuminate\Auth\Events\PasswordReset After password is reset

To listen for them, define a listener in App\Providers\EventServiceProvider:

use Illuminate\Auth\Events\Login;

protected $listen = [
    Login::class => [
        \App\Listeners\LogSuccessfulLogin::class,
    ],
];

The Full Flow, Visualized

flowchart TD
    A[User visits /dashboard] --> B{Logged in?}
    B -->|No| C[Redirect to /login]
    C --> D[User enters credentials]
    D --> E{Auth::attempt}
    E -->|Success| F[Session regenerated]
    F --> G[Redirect to /dashboard]
    E -->|Failure| H[Back with error]

    I[User visits /register] --> J{Fills form}
    J --> K[User::create]
    K --> L[Auth::login]
    L --> M[Redirect to /dashboard]

    N[User clicks Forgot Password] --> O[Enter email]
    O --> P{Password::sendResetLink}
    P --> Q[Email sent with token]
    Q --> R[User clicks link]
    R --> S[Enter new password]
    S --> T{Password::reset}
    T --> U[Password updated, redirected to login]

One More Thing: Login Throttling

Laravel’s default login route (if using routes/auth.php as shown) doesn’t include throttling by default. Add it to prevent brute-force attacks:

Route::post('/login', [LoginController::class, 'store'])
    ->middleware('throttle:5,1')  // 5 attempts per minute
    ->name('login');

Or wrap your auth POST routes:

Route::middleware('throttle:6,1')->group(function () {
    Route::post('/login', [LoginController::class, 'store']);
    Route::post('/forgot-password', [PasswordResetLinkController::class, 'store']);
});

TL;DR

You don’t need a starter kit. Manual auth in Laravel 13 boils down to:

  • Register: Validate input, User::create(), Auth::login(), regenerate session.
  • Login: Auth::attempt($credentials), regenerate session, redirect intended.
  • Logout: Auth::logout(), invalidate session, regenerate CSRF token.
  • Forgot Password: Password::sendResetLink($request->only('email')).
  • Reset Password: Password::reset(...) with a closure that forceFill + Hash::make.
  • Protect Routes: ->middleware('auth') for logged-in-only, ->middleware('guest') for not-logged-in-only.
  • Custom Middleware: make:middleware, register alias in bootstrap/app.php, stack with auth.

The whole thing takes maybe 30 minutes from scratch, and you’ll actually understand every line of your auth system. No magic. No scaffolding you’re afraid to touch. Just you and the code.