Cómo implementar recuperación de contraseña para administradores y clientes en Laravel 13

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 Admin y Customer para 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

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *