Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 Custom Forgot Password Tutorial: Send Reset Link and Reset Password

๐Ÿ› ๏ธ Laravel Tutorials6 min read

Laravel 13 Custom Forgot Password Tutorial: Send Reset Link and Reset Password

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 10, 2026
Laravel 13 Custom Forgot Password Tutorial: Send Reset Link and Reset Password

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 users table and a login system (the default User model is enough).
  • Mail configured in .env. For local work, set MAIL_MAILER=log so reset emails are written to storage/logs/laravel.log.
  • The password_reset_tokens table, which ships in the default 0001_01_01_000000_create_users_table migration. Run php artisan migrate if 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.

CSS
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.

JavaScript
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.

JavaScript
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.

Shell
<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.

Shell
<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().

PHP
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.reset is not defined, sending the email throws a "Route not defined" error.
  • Hashing twice. If your User model casts password to hashed, 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?

  1. Visit /forgot-password and submit a real user email.
  2. Open storage/logs/laravel.log (or Mailpit) and copy the reset link.
  3. Open the link, enter a new password twice and submit.
  4. 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.

References

Article Index & Topic Tags
LaravelAuthenticationPassword-ResetLaravel-13

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.