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.
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 byemail, verifies the hashed password, and starts a session. Returnstrueif 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: callsAuth::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():
- Laravel looks up the user by email via your user provider (by default, the
userstable). - It generates a random token and stores it in the
password_reset_tokenstable (created by the default migration). - It sends the user an email with a link containing that token. The email uses Laravel’s notification system via the
Usermodel’ssendPasswordResetNotification()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
Usermodel mustuse Notifiableand implementCanResetPassword. 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 thepassword_reset_tokenstable. If the token matches, is not expired, and belongs to the right email — the closure fires.forceFill()bypasses the$fillableguard. Sincepasswordandremember_tokenmight 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 thePasswordResetevent. 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.phpunderpasswords.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.phpfor simplicity. If you prefer, createroutes/auth.phpandrequireit fromweb.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 thatforceFill+Hash::make. - Protect Routes:
->middleware('auth')for logged-in-only,->middleware('guest')for not-logged-in-only. - Custom Middleware:
make:middleware, register alias inbootstrap/app.php, stack withauth.
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.