En proyectos web con áreas separadas para administradores y clientes, es común que ambos tipos de usuarios necesiten iniciar sesión, cerrar sesión y recuperar su contraseña. El reto técnico aparece cuando cada tipo de usuario vive en una tabla diferente y debe tener su propio flujo de autenticación.
En este artículo veremos cómo estructurar un proceso de recuperación de contraseña en Laravel 13 para dos tipos de usuarios: administradores y clientes. El objetivo es mantener una arquitectura limpia, segura y fácil de mantener.
Contexto del problema
Laravel Breeze instala un sistema de autenticación funcional, pero por defecto está pensado para trabajar principalmente con una sola tabla de usuarios, normalmente users.
Sin embargo, en proyectos más completos podemos necesitar algo como esto:
- Administradores: usuarios internos del sistema, almacenados en la tabla
users. - Clientes: usuarios externos que acceden al sitio o portal del cliente, almacenados en la tabla
customers.
En este escenario no conviene mezclar ambos tipos de usuario en la misma tabla, porque normalmente tienen permisos, vistas, reglas de negocio y flujos diferentes.
Estructura recomendada
app/
├── Http/
│ ├── Controllers/
│ │ ├── Admin/
│ │ │ └── Auth/
│ │ │ ├── PasswordResetLinkController.php
│ │ │ └── NewPasswordController.php
│ │ └── Customer/
│ │ └── Auth/
│ │ ├── PasswordResetLinkController.php
│ │ └── NewPasswordController.php
resources/
├── views/
│ ├── admin/
│ │ └── auth/
│ │ ├── forgot-password.blade.php
│ │ └── reset-password.blade.php
│ └── customer/
│ └── auth/
│ ├── forgot-password.blade.php
│ └── reset-password.blade.php
routes/
├── admin.php
└── customer.php
La idea es separar cada flujo para evitar dependencias innecesarias y tener rutas, controladores, vistas y brokers independientes.
Configuración de autenticación
En config/auth.php podemos definir un guard y un provider para cada tipo de usuario.
<?php
use App\Models\User;
use App\Models\Customer;
return [
'defaults' => [
'guard' => 'web',
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'customer' => [
'driver' => 'session',
'provider' => 'customers',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => User::class,
],
'customers' => [
'driver' => 'eloquent',
'model' => Customer::class,
],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
'customers' => [
'provider' => 'customers',
'table' => 'customer_password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
],
];
El punto clave está en la sección passwords. Ahí definimos dos brokers: users para administradores y customers para clientes. Cada broker usa su propio provider y su propia tabla de tokens.
Migración para tokens de clientes
Schema::create('customer_password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Esto permite que el flujo de administradores y clientes quede completamente separado.
Rutas para administradores
use App\Http\Controllers\Admin\Auth\PasswordResetLinkController;
use App\Http\Controllers\Admin\Auth\NewPasswordController;
use Illuminate\Support\Facades\Route;
Route::middleware('admin.guest')->group(function () {
Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('/reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
});
Rutas para clientes
use App\Http\Controllers\Customer\Auth\PasswordResetLinkController;
use App\Http\Controllers\Customer\Auth\NewPasswordController;
use Illuminate\Support\Facades\Route;
Route::middleware('customer.guest')->group(function () {
Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('/reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
});
Controlador para enviar el enlace de recuperación
El controlador encargado de enviar el enlace de recuperación debe usar el broker correcto.
Administrador
namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
class PasswordResetLinkController extends Controller
{
public function create()
{
return view('admin.auth.forgot-password');
}
public function store(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
]);
$status = Password::broker('users')->sendResetLink(
$request->only('email')
);
if ($status === Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => __($status),
]);
}
}
Cliente
namespace App\Http\Controllers\Customer\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
class PasswordResetLinkController extends Controller
{
public function create()
{
return view('customer.auth.forgot-password');
}
public function store(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
]);
$status = Password::broker('customers')->sendResetLink(
$request->only('email')
);
if ($status === Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => __($status),
]);
}
}
El cambio central es el broker:
Password::broker('users') // Administradores
Password::broker('customers') // Clientes
Controlador para actualizar la contraseña
Administrador
namespace App\Http\Controllers\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
class NewPasswordController extends Controller
{
public function create(Request $request)
{
return view('admin.auth.reset-password', [
'request' => $request,
]);
}
public function store(Request $request)
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$status = Password::broker('users')->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('admin.login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
Cliente
namespace App\Http\Controllers\Customer\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
class NewPasswordController extends Controller
{
public function create(Request $request)
{
return view('customer.auth.reset-password', [
'request' => $request,
]);
}
public function store(Request $request)
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$status = Password::broker('customers')->reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($customer) use ($request) {
$customer->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($customer));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('customer.login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
Personalizar la URL del correo de recuperación
Un detalle importante es que Laravel genera el enlace de recuperación usando una notificación. Si no personalizamos ese enlace, puede terminar enviando al usuario al flujo equivocado.
Para resolverlo, podemos agregar la lógica en app/Providers/AppServiceProvider.php.
namespace App\Providers;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
ResetPassword::createUrlUsing(function (object $notifiable, string $token): string {
if ($notifiable instanceof Customer) {
return URL::route('customer.password.reset', [
'token' => $token,
'email' => $notifiable->getEmailForPasswordReset(),
]);
}
if ($notifiable instanceof User) {
return URL::route('admin.password.reset', [
'token' => $token,
'email' => $notifiable->getEmailForPasswordReset(),
]);
}
return URL::route('customer.password.reset', [
'token' => $token,
'email' => $notifiable->getEmailForPasswordReset(),
]);
});
}
}
Con esto, cuando el modelo sea User, Laravel enviará al flujo de administrador. Cuando el modelo sea Customer, enviará al flujo de cliente.
Vistas de recuperación de contraseña
En cada formulario es importante apuntar a la ruta correcta.
Formulario para solicitar enlace
Administrador:
<form method="POST" action="{{ route('admin.password.email') }}">
@csrf
<input type="email" name="email" required>
<button type="submit">
Enviar enlace
</button>
</form>
Cliente:
<form method="POST" action="{{ route('customer.password.email') }}">
@csrf
<input type="email" name="email" required>
<button type="submit">
Enviar enlace
</button>
</form>
Formulario para crear nueva contraseña
Administrador:
<form method="POST" action="{{ route('admin.password.store') }}">
@csrf
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<input type="email" name="email" value="{{ old('email', $request->email) }}" required>
<input type="password" name="password" required>
<input type="password" name="password_confirmation" required>
<button type="submit">
Actualizar contraseña
</button>
</form>
Cliente:
<form method="POST" action="{{ route('customer.password.store') }}">
@csrf
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<input type="email" name="email" value="{{ old('email', $request->email) }}" required>
<input type="password" name="password" required>
<input type="password" name="password_confirmation" required>
<button type="submit">
Actualizar contraseña
</button>
</form>
Buenas prácticas
- Usar modelos separados cuando los usuarios internos y externos tienen responsabilidades diferentes.
- Separar rutas, vistas y controladores por tipo de usuario.
- Usar un broker de contraseña diferente para cada tabla.
- Personalizar la URL del correo para evitar que el usuario llegue al flujo equivocado.
- No depender completamente de la estructura por defecto de Breeze cuando el proyecto requiere múltiples áreas de autenticación.
- Mantener nombres claros como
AdminyCustomerpara facilitar el mantenimiento.
Conclusión
La recuperación de contraseña para múltiples tipos de usuarios en Laravel requiere separar correctamente guards, providers, brokers, rutas, controladores y vistas. Aunque Breeze ofrece una buena base inicial, en proyectos con administradores y clientes conviene tomar el control del flujo para evitar cruces entre autenticaciones.
La clave está en usar un broker distinto para cada tipo de usuario:
Password::broker('users') // Administradores
Password::broker('customers') // Clientes
Y personalizar el enlace generado por correo desde AppServiceProvider, para que cada usuario llegue al formulario correcto.
Con esta estructura, el sistema queda más limpio, seguro y preparado para crecer.
¡Si te ha servido de algo este artículo no olvides dejarnos un comentario!







Deja un comentario