Multi-Guard Authentication — Multiple User Types in One App
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 (admin, editor, viewer). 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 inauth.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.php,routes/admin.php,routes/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
adminandwebguards both use the same session driver without separateproviderconfigurations, an authenticated admin could inadvertently be recognized as a web user. Always define separate providers inconfig/auth.php.
💡 Tip: Name guard-specific middleware descriptively:
auth:admin,auth: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 (
users,admins,employees). Mixing user types in one table with arolecolumn leads to complex queries, fat models, and difficult-to-test authorization logic. Separate tables mean separate concerns.
Further Reading
- Laravel Docs: Authentication
- Laravel Docs: Guards
- Laravel Sanctum — for API token authentication per guard
- Laravel Docs: Middleware
One Comment