Multi-Guard Authentication — Multiple User Types in One App

Multi-Guard Authentication — Multiple User Types in One App

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 4 of 35 | Level: Intermediate | Prerequisites: AppServiceProvider


What You’ll Learn

  • What multi-guard authentication is and when you need it
  • How to configure separate guards for admin, user, and other roles
  • How to create per-guard middleware
  • How to handle login/logout/redirects correctly for each guard
  • How to check the authenticated user in controllers and views

Multi-Role vs. Multi-Guard

Before building anything, understand the distinction:

Role-based access means one user table, one login, different permissions. A User model with a role column (admineditorviewer). They all log in the same way — you just check their role afterwards. Use roles for users who are fundamentally the same type of person with different permission levels.

Multi-guard authentication means separate models, separate tables, separate sessions. An Admin model and a User model. They have different fields, different relationships, different login flows. Use multi-guard when the user types are fundamentally different entities with entirely separate backends.

Real example from these projects: a Merchandiser who logs in to view samples has nothing in common with an Admin who manages the catalog. They log in at different URLs, see completely different interfaces, and their sessions are completely separate.

/login          → authenticates against users table (User model, web guard)
/admin/login    → authenticates against admins table (Admin model, admin guard)
/employee/login → authenticates against employees table (Employee model, employee guard)

Step 1: Create the Separate Models and Migrations

Each authenticatable type needs its own model extending Authenticatable:

// app/Models/Admin.php
use Illuminate\Foundation\Auth\User as Authenticatable;

class Admin extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];
}
// app/Models/Merchandiser.php
use Illuminate\Foundation\Auth\User as Authenticatable;

class Merchandiser extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password', 'company_id'];
}

Each gets its own migration:

// database/migrations/create_admins_table.php
Schema::create('admins', function (Blueprint $table): void {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->string('password');
    $table->rememberToken();
    $table->timestamps();
});

Step 2: Configure Guards and Providers

In config/auth.php, register a guard and a provider for each user type:

// config/auth.php
return [
    'defaults' => [
        'guard'     => 'web',   // the default guard for your main users
        'passwords' => 'users',
    ],

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

        'admin' => [
            'driver'   => 'session',
            'provider' => 'admins',
        ],

        'merchandiser' => [
            'driver'   => 'session',
            'provider' => 'merchandisers',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model'  => App\Models\User::class,
        ],

        'admins' => [
            'driver' => 'eloquent',
            'model'  => App\Models\Admin::class,
        ],

        'merchandisers' => [
            'driver' => 'eloquent',
            'model'  => App\Models\Merchandiser::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table'    => 'password_reset_tokens',
            'expire'   => 60,
        ],
        'admins' => [
            'provider' => 'admins',
            'table'    => 'admin_password_reset_tokens',
            'expire'   => 60,
        ],
    ],
];

Step 3: Per-Guard Middleware

Create a RedirectIfNotAdmin middleware that checks the admin guard specifically:

// app/Http/Middleware/RedirectIfNotAdmin.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class RedirectIfNotAdmin
{
    public function handle(Request $request, Closure $next): mixed
    {
        if (! auth('admin')->check()) {
            return redirect()->route('admin.login');
        }

        return $next($request);
    }
}
// app/Http/Middleware/EnsureAdminEmailIsVerified.php
class EnsureAdminEmailIsVerified
{
    public function handle(Request $request, Closure $next): mixed
    {
        $admin = auth('admin')->user();

        if ($admin && ! $admin->hasVerifiedEmail()) {
            return redirect()->route('admin.verification.notice');
        }

        return $next($request);
    }
}

Register middleware in bootstrap/app.php (Laravel 11+) or Kernel.php (Laravel 10 and below):

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'auth.admin'           => RedirectIfNotAdmin::class,
        'admin.email.verified' => EnsureAdminEmailIsVerified::class,
    ]);
})

Step 4: Separate Route Groups

Each guard gets its own route group, each protected by its middleware:

// routes/web.php — regular users
Route::middleware(['auth'])->group(function (): void {
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
    Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');
});

Route::middleware(['guest'])->group(function (): void {
    Route::get('/login', [LoginController::class, 'create'])->name('login');
    Route::post('/login', [LoginController::class, 'store']);
});
// routes/admin.php — admin users
Route::prefix('admin')->name('admin.')->group(function (): void {
    Route::middleware(['guest:admin'])->group(function (): void {
        Route::get('/login', [AdminLoginController::class, 'create'])->name('login');
        Route::post('/login', [AdminLoginController::class, 'store']);
    });

    Route::middleware(['auth.admin', 'admin.email.verified'])->group(function (): void {
        Route::get('/dashboard', [Admin\DashboardController::class, 'index'])->name('dashboard');
        Route::resource('orders', Admin\OrderController::class);
    });
});

Load the admin routes in your AppServiceProvider or RouteServiceProvider:

// AppServiceProvider::boot() or RouteServiceProvider::boot()
Route::middleware('web')
    ->group(base_path('routes/admin.php'));

Step 5: Auth Controllers per Guard

Each guard needs its own login controller that specifies which guard to use:

// app/Http/Controllers/Auth/Admin/AuthenticatedSessionController.php
class AuthenticatedSessionController extends Controller
{
    public function store(LoginRequest $request): RedirectResponse
    {
        $credentials = $request->only('email', 'password');

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

        $request->session()->regenerate();

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

    public function destroy(Request $request): RedirectResponse
    {
        auth('admin')->logout();

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

        return redirect()->route('admin.login');
    }
}

Checking Auth in Controllers and Views

Always specify the guard:

// Controller
auth('admin')->user();        // returns the authenticated Admin or null
auth('admin')->check();       // bool
auth('admin')->id();          // int|null
auth('admin')->guest();       // bool

// Log out a specific guard
auth('admin')->logout();

// For the default guard (web), you can omit the guard name
auth()->user();
auth()->check();

In Blade views, use the custom @role directive (registered in RolesServiceProvider):

// app/Providers/RolesServiceProvider.php
Blade::directive('role', function (string $arguments): string {
    [$role, $guard] = array_pad(explode(',', $arguments), 2, null);
    return "<?php if(auth({$guard})->check() && auth({$guard})->user()->hasRole({$role})): ?>";
});

Blade::directive('endrole', fn (): string => '<?php endif; ?>');
@role('editor', 'admin')
    <a href="{{ route('admin.posts.create') }}">Create Post</a>
@endrole

Key Takeaways

  • Use multi-guard when you have fundamentally different user types with separate tables, not just different permissions for the same user type.
  • Each guard needs its own model, migration, provider entry in auth.php, and guard entry in auth.php.
  • Each guard should have its own middleware, route group, and login/logout controller.
  • Always specify the guard name when calling auth('guard-name') in controllers and middleware.
  • Keep route files organized per guard: routes/web.phproutes/admin.phproutes/merchandiser.php.


Frequently Asked Questions

Can one user be both an admin and a regular user? You can achieve this with a single User model and a role column. But separate User and Admin models are usually cleaner — admins can’t accidentally access user-scoped routes, the two models can have completely different fields, and you avoid if ($user->role === 'admin') checks scattered everywhere. The projects in this series all use separate models.

Do I need separate login pages for each guard? Yes, and that’s the point. /login for users, /admin/login for admins. This prevents accidental cross-guard sessions and lets you apply different security policies per guard (IP allow-listing, stricter session timeouts, 2FA requirements for admins only).

What happens if I forget to specify the guard in a controller? Laravel uses the web guard by default. auth()->user()returns the web guard’s user. If an admin is logged in via the admin guard, auth()->user() returns null. Always specify: auth('admin')->user().


Tips and Gotchas

⚠️ Warning: Never share a session or token between guards. If admin and web guards both use the same session driver without separate provider configurations, an authenticated admin could inadvertently be recognized as a web user. Always define separate providers in config/auth.php.

💡 Tip: Name guard-specific middleware descriptively: auth:adminauth:employee. In route files, this reads like documentation — Route::middleware(['auth:admin']) tells the next developer exactly what guard is required.

🔥 Expert Note: Use separate database tables per guard type (usersadminsemployees). Mixing user types in one table with a role column leads to complex queries, fat models, and difficult-to-test authorization logic. Separate tables mean separate concerns.

Further Reading


← AppServiceProvider | Next: Thin Controllers →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment