Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Login, Registration & Logout

Guards, hashing, remember-me, password resets and protecting routes.

Almost every app needs to know who is making the request. Laravel's authentication system answers that with three pieces: guards (how a user is identified on each request), providers (where users are loaded from) and the session (how the login is remembered between requests).

This site uses all three. The admin panel at /admin is a real login form backed by Laravel's web guard, so the code below is the code that protects this site.

How a login works

A web login is a short conversation between the browser, the auth middleware and the session:

GET /admin no session yet auth middleware Auth::check() is false 302 → /admin/login "intended" URL saved POST credentials email + password + CSRF Auth::attempt() find user, Hash::check() session regenerate user id stored redirect()->intended() → every later request carries the session cookie → Auth::user() works

Step through it below. Each step shows what the browser sends, what Laravel does and what ends up in the session:

Guards and providers

Guards and providers are configured in config/auth.php. A fresh Laravel app, including this one, ships with one web guard that stores the login in the session and loads users with Eloquent:

config/auth.php

'defaults' => [
    'guard' => env('AUTH_GUARD', 'web'),
    'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
],

'providers' => [
    'users' => [
        'driver' => 'eloquent',
        'model' => env('AUTH_MODEL', User::class),
    ],
],
Piece Answers Common values
Guard How do I recognise the user on this request? session (web pages), token / Sanctum (APIs)
Provider Where do I load the user from? eloquent (a model), database (a table)
Password broker How are reset tokens stored? password_reset_tokens table

You only add guards when you have genuinely different kinds of user, such as customers and staff stored in separate tables.

Storing passwords safely

Never store a password you can read back. Laravel hashes with bcrypt (or argon2) and checks with Hash::check(). This app's User model uses the hashed cast, so assigning a plain password hashes it automatically:

app/Models/User.php

protected $hidden = ['password', 'remember_token'];

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

$hidden keeps the hash out of toArray() and JSON responses. The admin account itself is created from .env values by a seeder, and the hashed cast does the hashing:

database/seeders/AdminUserSeeder.php

public function run(): void
{
    $email = config('admin.email');
    $password = config('admin.password');

    if (blank($email) || blank($password)) {
        $this->command?->warn('ADMIN_EMAIL / ADMIN_PASSWORD not set — no admin account created.');

        return;
    }

    User::updateOrCreate(
        ['email' => $email],
        ['name' => config('admin.name'), 'password' => $password],
    );
}

The two Hash methods you will use directly:

$hash = Hash::make('secret');          // $2y$12$K3z... (different every time: random salt)
Hash::check('secret', $hash);          // true
Hash::needsRehash($hash);              // true after you raise the bcrypt cost

Logging in and out

A login controller is three calls: Auth::attempt(), session()->regenerate() and redirect()->intended(). Illustrative — this site's login form is provided by Filament, which does the same thing internally:

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

    if (! Auth::attempt($credentials, remember: $request->boolean('remember'))) {
        return back()
            ->withErrors(['email' => 'These credentials do not match our records.'])
            ->onlyInput('email');
    }

    $request->session()->regenerate();     // stops session fixation

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

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

    $request->session()->invalidate();     // wipe the session data
    $request->session()->regenerateToken(); // new CSRF token

    return redirect('/');
}

Always regenerate the session id after login. If you skip it, an attacker who planted a session id in the victim's browser before login would share the logged-in session. That attack is called session fixation.

Use the same error message for "unknown email" and "wrong password", so the form can't be used to find out which emails have accounts.

Registration

Registering is "validate, create, log in". Illustrative — this site has no public sign-up:

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

    $user = User::create($validated);      // 'hashed' cast hashes the password

    event(new Registered($user));          // sends the verification email if enabled
    Auth::login($user);

    return redirect('/dashboard');
}

Password::defaults() lets you set the rules in one place, for example in AppServiceProvider::boot():

Password::defaults(fn () => app()->isProduction()
    ? Password::min(12)->mixedCase()->numbers()->uncompromised()
    : Password::min(8));

Protecting routes

Put the auth middleware on anything that needs a logged-in user, and guest on pages only for visitors:

Route::middleware('auth')->group(function () {
    Route::get('/dashboard', DashboardController::class);
    Route::resource('projects', ProjectController::class);
});

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

Inside a controller or view you can read the current user several ways:

Code Returns
Auth::user() / auth()->user() / $request->user() The User model, or null for guests
Auth::id() Just the id, without loading the model
Auth::check() / @auth ... @endauth Whether someone is logged in

Who may log in: this site's admin gate

Being logged in is one question. Being allowed in is another. Filament asks the user model through canAccessPanel(). On this site only the single account named in .env passes:

app/Models/User.php

/**
 * Only the account configured as ADMIN_EMAIL may use the admin panel.
 */
public function canAccessPanel(Panel $panel): bool
{
    $adminEmail = config('admin.email');

    return filled($adminEmail) && strcasecmp($this->email, $adminEmail) === 0;
}

config/admin.php

return [
    'name' => env('ADMIN_NAME', 'Admin'),
    'email' => env('ADMIN_EMAIL'),
    'password' => env('ADMIN_PASSWORD'),
];

The tutorial's demo users (Ava, Ben, Chloe and Dan) exist only as task assignees. They have random 64-character passwords and are refused by this check even if somebody guessed one. That rule is pinned down by a feature test, where owner@example.test is the admin email set for the test run:

tests/Feature/AdminAccessTest.php (excerpt)

public function test_other_users_are_refused_by_the_admin_panel(): void
{
    $this->actingAs(User::factory()->create())
        ->get('/admin')
        ->assertForbidden();

    $this->actingAs(User::factory()->create(['email' => 'owner@example.test']))
        ->get('/admin')
        ->assertOk();
}

actingAs() is the testing shortcut for "log this user in". You never need to post a login form in a test. For finer-grained rules ("may this user edit this task?") use policies, covered in Middleware, Auth & Policies.

Remember me and password resets

Remember me. Pass remember: true to Auth::attempt(). Laravel then stores a long-lived token in the remember_token column and in a cookie, so the user stays logged in after the session expires.

Password resets. The password_reset_tokens table and the Password facade cover the whole flow. Illustrative:

// 1. Email a reset link
$status = Password::sendResetLink($request->only('email'));

// 2. Later, reset with the token from the link
$status = Password::reset(
    $request->only('email', 'password', 'password_confirmation', 'token'),
    function (User $user, string $password) {
        $user->forceFill(['password' => $password])->setRememberToken(Str::random(60));
        $user->save();
        event(new PasswordReset($user));
    },
);

Starter kits and API tokens

You rarely write all of this by hand. laravel new offers starter kits (React, Vue or Livewire) that generate login, registration, password reset, email verification and profile pages. You can then edit them like your own code.

For APIs and mobile apps, sessions don't fit. Laravel Sanctum issues API tokens and also authenticates your own SPA through its session cookie. Illustrative — Sanctum isn't installed on this site; its demo API is public and read-only:

$token = $user->createToken('mobile-app', ['tasks:read'])->plainTextToken;

Route::middleware('auth:sanctum')->get('/api/me', fn (Request $request) => $request->user());

Clients send the token as Authorization: Bearer <token>.

Key takeaways

  • A guard identifies the user on each request (session for web pages, tokens for APIs). A provider loads them, usually through Eloquent.
  • Store passwords only as hashes. The hashed cast plus $hidden handle that on the User model.
  • Logging in means Auth::attempt(), then session()->regenerate(), then redirect()->intended(). Logging out also invalidates the session and regenerates the CSRF token.
  • Protect routes with the auth middleware. Decide who may enter separately, as this site's canAccessPanel() does.
  • Use actingAs() in tests, starter kits for full auth UIs, and Sanctum for API tokens.