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
usersmigration is enough. - The default
App\Models\Usermodel, which already castspasswordashashed.
Step 1: Define the routes
Group the guest-only pages and the authenticated pages separately in routes/web.php:
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
<?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:
<?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:
<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
passwordashashed, callingHash::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_tokencolumn. 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:
<?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].



