Dois logins no Laravel com guards: cliente e equipe sem misturar a sessão
Configure um guard e um provider para cada tipo de usuário, proteja as rotas com auth:guard, separe a recuperação de senha e faça o logout de um sem derrubar o outro.
A analista de suporte da Torra Fina, loja fictícia, recupera a senha da área interna e cai na tela "Meus pedidos" de cliente. Dias depois, um cliente com o mesmo e-mail de um funcionário pede redefinição de senha e o link chega para a conta errada. Os dois problemas têm a mesma origem: uma tabela users só, com uma coluna is_admin e um if no controller. A resposta curta: no Laravel, dê a cada tipo de pessoa o seu guard, o seu provider, a sua tabela e o seu broker de senha. As rotas internas exigem auth:equipe, as do cliente exigem auth:web, e uma sessão não abre a porta da outra.
Os exemplos usam a estrutura do Laravel 11 e 12 (configuração de middleware em bootstrap/app.php). A ideia vale para versões anteriores, mudando o lugar onde alguns trechos ficam.
Por que um usuário com flag não basta
Cliente e funcionário usam e-mail e senha, mas não são a mesma entidade. Têm cadastros diferentes, regras de senha diferentes, telas de login diferentes e, muitas vezes, campos de entrada diferentes: o cliente entra com e-mail, a equipe com matrícula. Colocar todos na mesma tabela obriga cada parte do sistema a lembrar de conferir a flag. Basta esquecer em um lugar para um cliente ver o que não devia.
No Laravel, a autenticação tem duas peças. O guard define como a pessoa é lembrada entre requisições (na sessão, por exemplo). O provider define de onde ela vem (qual modelo, qual tabela). Com dois pares de guard e provider, você tem dois mundos que não se enxergam. O post dois logins no mesmo sistema discute essa decisão de forma independente de framework.
Como configurar os dois logins, passo a passo
- Crie a tabela e o modelo da equipe, separados de
users. - Declare o guard e o provider
equipeemconfig/auth.php. - Declare um broker de senha próprio, com tabela de tokens própria.
- Agrupe as rotas internas com
guest:equipeeauth:equipe. - Escreva o login da equipe usando
Auth::guard('equipe'). - Ensine o Laravel para onde mandar visitantes de cada área.
- Faça o logout de um guard sem destruir a sessão inteira, se cliente e equipe puderem estar logados no mesmo navegador.
Modelo e configuração
// app/Models/Funcionario.php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Funcionario extends Authenticatable
{
use Notifiable;
protected $table = 'funcionarios';
protected $fillable = ['nome', 'matricula', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['password' => 'hashed'];
}
}
// config/auth.php (trechos)
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'equipe' => ['driver' => 'session', 'provider' => 'funcionarios'],
],
'providers' => [
'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class],
'funcionarios' => ['driver' => 'eloquent', 'model' => App\Models\Funcionario::class],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
'funcionarios' => [
'provider' => 'funcionarios',
'table' => 'funcionario_password_reset_tokens',
'expire' => 30,
'throttle' => 60,
],
],
A tabela de tokens separada não é capricho. A tabela padrão usa o e-mail como chave; se um cliente e um funcionário tiverem o mesmo e-mail, os tokens de um sobrescrevem os do outro. Crie funcionario_password_reset_tokens com a mesma estrutura da padrão (email, token, created_at).
Rotas e login da equipe
// routes/web.php
use App\Http\Controllers\Equipe\LoginController;
use App\Http\Controllers\Equipe\PainelController;
Route::prefix('equipe')->name('equipe.')->group(function () {
Route::middleware('guest:equipe')->group(function () {
Route::get('login', [LoginController::class, 'create'])->name('login');
Route::post('login', [LoginController::class, 'store']);
});
Route::middleware('auth:equipe')->group(function () {
Route::get('/', PainelController::class)->name('painel');
Route::post('logout', [LoginController::class, 'destroy'])->name('logout');
});
});
// app/Http/Controllers/Equipe/LoginController.php (métodos store e destroy)
public function store(Request $request)
{
$credenciais = $request->validate([
'matricula' => ['required', 'string'],
'password' => ['required', 'string'],
]);
if (! Auth::guard('equipe')->attempt($credenciais, $request->boolean('lembrar'))) {
throw ValidationException::withMessages([
'matricula' => 'Matrícula ou senha inválidas.',
]);
}
$request->session()->regenerate();
return redirect()->route('equipe.painel');
}
public function destroy(Request $request)
{
Auth::guard('equipe')->logout();
$request->session()->regenerateToken();
return redirect()->route('equipe.login');
}
O attempt do provider Eloquent busca o registro pelos campos que não são senha, então matricula funciona sem configuração extra, e confere a senha com o hash. A mensagem de erro não diz qual dos dois campos falhou, pelo motivo explicado em login que diz "usuário não existe". O regenerate troca o id da sessão no login e evita fixação de sessão.
Repare no logout: ele chama Auth::guard('equipe')->logout() e regenera o token CSRF, mas não chama session()->invalidate(). Os dois guards usam a mesma sessão do navegador, cada um com sua chave. Invalidar a sessão inteira derrubaria também o login de cliente que estivesse aberto na mesma janela. Se a sua regra for "sair de tudo", invalide de propósito; só não faça isso por ter copiado o controller padrão.
Para onde vai quem não está logado
Sem configuração, o middleware auth manda todo visitante para a rota login, que é a do cliente. Em bootstrap/app.php, decida pelo caminho:
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->redirectGuestsTo(fn (Request $request) =>
$request->is('equipe', 'equipe/*') ? route('equipe.login') : route('login')
);
})
Dentro de uma rota protegida por auth:equipe, o middleware também define esse guard como o padrão da requisição. Por isso $request->user(), Auth::user() e as policies devolvem o funcionário logado, sem você repetir o nome do guard em todo lugar.
Recuperação de senha de cada porta
Use Password::broker('funcionarios') no fluxo da equipe e o broker padrão no do cliente. O e-mail de redefinição da equipe precisa apontar para a rota interna; sobrescreva no modelo Funcionario o método sendPasswordResetNotification($token) para enviar uma notificação própria com o link de /equipe. Se a equipe entra por matrícula, ainda é preciso um e-mail cadastrado para o envio, e essa mudança de campo tem efeitos que o post campo de login nem sempre é e-mail detalha.
Erros comuns
- Uma tabela
userscom flag de administrador. Cada rota precisa lembrar de conferir a flag, e o esquecimento vira acesso indevido. - Proteger rotas internas só com
auth. O guard padrão é o do cliente, e um cliente logado passa pelo middleware. - Compartilhar a tabela de tokens de senha. E-mails iguais nos dois cadastros fazem um token apagar o outro.
- Copiar o logout padrão com
invalidate. Sair da área interna desloga também o cliente aberto no mesmo navegador. - Esquecer o redirecionamento de visitantes. O funcionário que perdeu a sessão cai na tela de login de cliente e acha que a conta sumiu.
- Mensagem de erro que diz qual campo falhou. Quem está de fora descobre quais matrículas existem.
Checklist
- Cliente e equipe têm tabelas, modelos, providers e guards próprios.
- Todas as rotas internas estão dentro de
auth:equipe. - Cada guard tem seu broker de senha e sua tabela de tokens.
- O e-mail de redefinição da equipe aponta para a área interna.
- O logout de um guard não derruba o outro, a menos que isso seja a regra.
- Visitantes de
/equipesão enviados ao login da equipe. - O login regenera a sessão e a mensagem de erro é genérica.
Abra duas janelas anônimas, entre como cliente numa e como funcionário na outra, e depois faça as duas coisas na mesma janela. Sair da área interna derruba o cliente, e o link de "esqueci a senha" de cada porta chega para a conta certa?