In a Laravel API behind a separate SPA, how do you make reset and verification emails link to the front end instead of Laravel routes?
answer
- static callbacks set in boot()
- ResetPassword gets user and token
- VerifyEmail gets only the notifiable
- sign the backend URL yourself
- missing password.reset route throws
basics
~10 sRegister URL builders in AppServiceProvider::boot(): ResetPassword::createUrlUsing(fn ($user, $token) => ...) returns a front-end URL with the token and email, and VerifyEmail::createUrlUsing(fn ($notifiable) => ...) must build and embed the signed verification URL itself.
solid answer
~40 sBoth stock notifications expose a static `createUrlUsing()` hook, normally set in `AppServiceProvider::boot()`. `ResetPassword::createUrlUsing()` receives the user and the plain token, so you return something like the front end's `/reset-password?token=...&email=...`; the SPA then posts email, token and password to an API route that calls `Password::reset()`. Without the hook, the notification calls `route('password.reset')`, which throws `RouteNotFoundException` in an API with no such named route. `VerifyEmail::createUrlUsing()` receives only the notifiable, not a finished URL, so you must generate the signed backend URL yourself with `URL::temporarySignedRoute('verification.verify', ...)`, id and `sha1` hash, and pass it to the front end, which calls it with the user's session or token. `toMailUsing()` is the alternative when you want to rewrite the whole message: for `VerifyEmail` it receives the already-signed URL.
go deeper
Recall that the two notifications have a static createUrlUsing() hook you set in AppServiceProvider::boot() to point links at the front end.
Explain the different callback arguments of the reset and verification hooks, and how the SPA hands the token or signed URL back to the API.
Avoid the traps: static state set per request, missing named routes, host-dependent default links, and verification links that lose their signature.
Decide whether auth emails should be owned by the API or the front-end team, and keep one source of truth for link lifetimes and URLs.
## The problem On an e-learning platform whose student app is a separate single-page application, Laravel serves only JSON. The two built-in auth emails, however, point at Laravel routes by default: - `Illuminate\Auth\Notifications\ResetPassword` builds `url(route('password.reset', ['token' => ..., 'email' => ...], false))`. - `Illuminate\Auth\Notifications\VerifyEmail` builds `URL::temporarySignedRoute('verification.verify', ...)`. If the API has no route named `password.reset`, the reset email fails with `RouteNotFoundException` ("Route [password.reset] not defined."). Even when routes exist, the student would land on a bare JSON endpoint instead of the app. ## `ResetPassword::createUrlUsing()` - It is a **static** method that stores a callback on the notification class; call it once in `AppServiceProvider::boot()`. - The callback receives **the notifiable (the user) and the plain token** and must return the full URL string. - The front-end page reads `token` and `email` from the query string, shows the new-password form and posts `email`, `token`, `password` and `password_confirmation` to an API endpoint that calls `Password::reset()`. ```php ResetPassword::createUrlUsing(function (User $user, string $token) { return config('app.frontend_url').'/reset-password?'.http_build_query([ 'token' => $token, 'email' => $user->getEmailForPasswordReset(), ]); }); ``` `app.frontend_url` here is a key you add to `config/app.php` yourself; it is not in the skeleton. ## `VerifyEmail::createUrlUsing()` The verification hook is **not** symmetric: | Hook | Callback arguments | You must build | |---|---|---| | `ResetPassword::createUrlUsing` | `$notifiable`, `$token` | a URL containing the token | | `VerifyEmail::createUrlUsing` | `$notifiable` only | the whole signed URL, then wrap it | | `VerifyEmail::toMailUsing` | `$notifiable`, `$url` (already signed) | the `MailMessage` | | `ResetPassword::toMailUsing` | `$notifiable`, `$token` | the `MailMessage` | Because the verification callback replaces the URL-building step entirely, it receives no signature. A common pattern: 1. Inside the callback, call `URL::temporarySignedRoute('verification.verify', now()->addMinutes(60), ['id' => $user->getKey(), 'hash' => sha1($user->getEmailForVerification())])`. 2. Put that URL (URL-encoded) in a query parameter of a front-end route such as `/verify-email?url=...`. 3. The SPA calls the signed backend URL with the student's session cookie or token; the backend route still carries `auth` and `signed` and uses `EmailVerificationRequest`. ## The API endpoint the SPA posts back to The backend half stays an ordinary broker call that answers in JSON rather than redirecting: ```php Route::post('/reset-password', function (Request $request) { $request->validate([ 'token' => 'required', 'email' => 'required|email', 'password' => 'required|min:8|confirmed', ]); $status = Password::reset( $request->only('email', 'password', 'password_confirmation', 'token'), fn (User $user, string $password) => $user->forceFill(['password' => Hash::make($password)])->save() ); return $status === Password::PasswordReset ? response()->json(['message' => __($status)]) : response()->json(['message' => __($status)], 422); }); ``` The token never needs to touch a Laravel view: it travels email, front-end query string, then the JSON body of this request. ## Practical notes - **Global static state.** Both hooks set static properties, so they apply to every future instance of the notification. Setting them in `boot()` is the intended use; setting them inside a request handler leaks into later mails in the same process. - **Host independence.** The default reset URL takes its host from the current request. An explicit front-end URL no longer depends on the incoming `Host` header, which removes one spoofing route. - **Override per model instead.** If you need a completely different notification class, override `sendPasswordResetNotification($token)` or `sendEmailVerificationNotification()` on the user model. - **Keep the backend route names** that other code relies on: the `verified` middleware still redirects HTML requests to `verification.notice`, though JSON clients get a 403. ## Summary - `ResetPassword::createUrlUsing()` gets the token; build a front-end URL with it. - `VerifyEmail::createUrlUsing()` gets no URL; sign one yourself or use `toMailUsing()`. - Register both in `boot()`, never per request.
- Why does VerifyEmail::toMailUsing receive a URL while VerifyEmail::createUrlUsing does not?`toMail()` first calls `verificationUrl()`, which either runs your `createUrlUsing` callback or builds the default signed route, and only then hands that URL to a `toMailUsing` callback. `createUrlUsing` replaces the URL-building step itself, so there is nothing to receive; you generate the signed URL inside it.
- What error appears if an API-only Laravel app sends the stock reset email without a password.reset route or a createUrlUsing hook?The notification's `resetUrl()` calls `route('password.reset', ...)`, and the URL generator throws `Symfony\Component\Routing\Exception\RouteNotFoundException` with "Route [password.reset] not defined." Define the hook, or a named route that redirects to the front end.
saying these in an interview costs you the question
- VerifyEmail::createUrlUsing receives the already-signed verification URL
- createUrlUsing should be called in the controller before each send
- An API without a password.reset route sends the stock reset email fine
- ResetPassword::createUrlUsing lets you skip Password::reset on the backend