To build a custom forgot password flow in Laravel 13, use the Password facade: Password::sendResetLink() emails a signed reset token, and Password::reset() validates the token and lets you save the new password. You write two small controllers and four routes, and Laravel handles token storage, expiry and throttling for you.
This tutorial targets Laravel 13 (released March 2026, which requires PHP 8.3 to 8.5). It does not use a starter kit, so you keep full control over the views and the response format. The code was written for this article and not executed in a live project, so run it in a fresh app first.
What do you need before you start?
- PHP 8.3 or newer and Composer.
- A Laravel 13 app with a working
userstable and a login system (the defaultUsermodel is enough). - Mail configured in
.env. For local work, setMAIL_MAILER=logso reset emails are written tostorage/logs/laravel.log. - The
password_reset_tokenstable, which ships in the default0001_01_01_000000_create_users_tablemigration. Runphp artisan migrateif you have not.
How does the password reset flow work?
The flow has four steps. The user opens a form and submits an email. Laravel stores a hashed token in password_reset_tokens and emails a link that contains the plain token. The link opens a second form that posts the token, email and new password. Laravel checks the token against the hash, and if it is valid and not expired, your callback updates the user.
Step 1: Which routes do you add?
Add four routes to routes/web.php. The reset route must be named password.reset because the built-in notification uses that name to build the email link.
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\ResetPasswordController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('/forgot-password', [ForgotPasswordController::class, 'create'])
->name('password.request');
Route::post('/forgot-password', [ForgotPasswordController::class, 'store'])
->middleware('throttle:6,1')
->name('password.email');
Route::get('/reset-password/{token}', [ResetPasswordController::class, 'create'])
->name('password.reset');
Route::post('/reset-password', [ResetPasswordController::class, 'store'])
->name('password.update');
});Step 2: How do you send the reset link?
Create the controller with php artisan make:controller Auth/ForgotPasswordController. It validates the email and asks the password broker to send the link.
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{
public function create()
{
return view('auth.forgot-password');
}
public function store(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink($request->only('email'));
return $status === Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
}Password::sendResetLink() returns a status string such as passwords.sent, passwords.user or passwords.throttled. Translating it with __() gives readable text from lang/en/passwords.php. If you want to avoid revealing which emails exist, always show the same success message regardless of the status.
Step 3: How do you reset the password?
Create ResetPasswordController. The store method validates the input and passes a closure to Password::reset(). The closure runs only when the token is valid.
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
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\Password as PasswordRule;
class ResetPasswordController extends Controller
{
public function create(Request $request, string $token)
{
return view('auth.reset-password', [
'token' => $token,
'email' => $request->query('email'),
]);
}
public function store(Request $request)
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(8)],
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
}Regenerating remember_token signs the user out of any other device that used "remember me". Firing PasswordReset lets other listeners react, for example to log the event.
Step 4: Which views do you need?
Create resources/views/auth/forgot-password.blade.php with an email field, a CSRF token and the status message.
<form method="POST" action="{{ route('password.email') }}">
@csrf
@if (session('status')) <p>{{ session('status') }}</p> @endif
<input type="email" name="email" value="{{ old('email') }}" required>
@error('email') <p>{{ $message }}</p> @enderror
<button type="submit">Email reset link</button>
</form>Then create reset-password.blade.php. It posts the hidden token plus the email, password and confirmation fields.
<form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ $token }}">
<input type="email" name="email" value="{{ old('email', $email) }}" required>
<input type="password" name="password" required>
<input type="password" name="password_confirmation" required>
@error('email') <p>{{ $message }}</p> @enderror
@error('password') <p>{{ $message }}</p> @enderror
<button type="submit">Reset password</button>
</form>How do you customise the reset email?
The default email is sent by Illuminate\Auth\Notifications\ResetPassword. To point the link at a different front end, call ResetPassword::createUrlUsing() in AppServiceProvider::boot().
use Illuminate\Auth\Notifications\ResetPassword;
ResetPassword::createUrlUsing(function ($user, string $token) {
return config('app.frontend_url') . '/reset-password?token=' . $token
. '&email=' . urlencode($user->getEmailForPasswordReset());
});Token lifetime and throttling are set in config/auth.php under passwords.users: expire is in minutes (60 by default) and throttle is the number of seconds a user must wait before requesting another link.
What are the common mistakes?
- Missing route name. If
password.resetis not defined, sending the email throws a "Route not defined" error. - Hashing twice. If your
Usermodel castspasswordtohashed,Hash::make()is harmless but redundant; do not hash it a third time in an observer. - Forgetting the email on the reset form. The broker looks the user up by email, so the form must post it together with the token.
- Using the wrong password broker. If you use a custom provider, pass the broker name to
Password::broker('name'). - Leaking account existence. Returning "user not found" tells attackers which emails are registered.
How do you verify it works?
- Visit
/forgot-passwordand submit a real user email. - Open
storage/logs/laravel.log(or Mailpit) and copy the reset link. - Open the link, enter a new password twice and submit.
- Log in with the new password. Reusing the same link must now fail with the "invalid token" message.
You can also write a feature test with Notification::fake(), call the endpoint, then assert Notification::assertSentTo($user, ResetPassword::class).
FAQ
Where does Laravel store reset tokens?
In the password_reset_tokens table, hashed, together with the email and a timestamp.
How long is a reset link valid?
60 minutes by default. Change expire in config/auth.php.
Can I use this for an API?
Yes. Return JSON instead of redirects from the same two controller methods and let your front end build the reset page.
Do I need Breeze or Fortify?
No. They automate the same Password facade calls shown here. Writing it yourself is useful when you need custom views or responses.



