Sitemap
Press enter or click to view image in full size

How to implement Email Verification to your PHP Laravel App?

--

“Today, most apps and websites require email verification for multiple reasons — even though it adds an extra step to the onboarding process. When done right, email verification provides a safer environment for users and keeps the app secured against potential fraud and wasted resources. It also helps with critical cases like password recovery and marketing communication. Today I’m going to show you how to add email verification to your PHP Laravel API with an Angular frontend.”

app/Models/User.php

<?php

use Illuminate\Contracts\Auth\MustVerifyEmail;

class User extends Authenticatable implements MustVerifyEmail
{
// ...
}

After you adding this interface to your model, newly registered users will be needed verify the email to see secured routes.

database/migrations/…_create_login_otps_table.php

To store the OTP(One-Time Password) codes we create a new DB table. And the OTP codes will live for 10 min after created.

Schema::create('login_otps', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('code', 6); // 6 digits code
$table->timestamp('expires_at'); // expires in 10 mins
$table->timestamps();
});

app/Services/OtpService.php

OtpService- The brain

we create 2 methods here send() to generate the code and send; verify() to verify the code.

public function send(User $user): void
{
// Dekete the recent codes if still exists
LoginOtp::where('user_id', $user->id)->delete();
// generating 6 digits code
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// DB
LoginOtp::create([
'user_id' => $user->id,
'code' => $code,
'expires_at' => now()->addMinutes(10), // sets expiry 10 mins from creation
]);
// Sending email
Mail::raw(
"'Myproject' Security Code: {$code}\n\nThis code is valid for 10 minutes.",
fn($msg) => $msg->to($user->email)->subject('Login Verification
Code')
);
}
public function verify(User $user, string $code): bool
{
$otp = LoginOtp::where('user_id', $user->id)
->where('code', $code)
->where('expires_at', '>', now()) // Expired?
->first();
if (!$otp) {
return false;
}
$otp->delete(); // Delete afterwards
return true;
}

str_pad(random_int(0, 999999), 6, ‘0’, STR_PAD_LEFT) =>It always generates a 6-digit OTP, which can start with a 0.

app/Http/Controllers/AuthController.php

AuthController — Flow Controller

We add eventListener to register() method we created before. This line runs email verification trigger. This fires a Laravel event — any registered listener (like the email verification sender) will pick it up automatically.

use Illuminate\Auth\Events\Registered;

public function register(Request $request)
{
//
event(new Registered($user));
//
}

login() — Confirm Password, Send OTP

public function login(Request $request, OtpService $otpService)
{
//
// Send OTP instead of issuing a token directly
$otpService->send($user);
//
}

verifyOtp() — Verify OTP, Issue Token

This function verifies the OTP code for a user based on their email and provided code. If the verification is successful, it generates an authentication token and returns the user data with the token as a JSON response

public function verifyOtp(Request $request) {
$request->validate([
'email' => 'required|email',
'code' => 'required|string|size:6',
]);

$user = User::Where('email', $request->email)->first();

if(!$user || !$this->otpService->verify($user, $request->code)){
return response()->json(['message' => 'Invalid or expired OTP'], 401);
}

//OTP true
$token = $user->createToken('auth_token')->plainTextToken;

return response()->json([
'user' =>$user,
'token' => $token,
]);

}
public function verifyEmail(Request $request, $id, $hash)
{
$user = User::findOrFail($id);

if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
abort(403);
}

if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}

return redirect('http://localhost:8085/dashboard?verified=1');
}

hash_equals() => Securely compares two strings to prevent timing attacks.

sha1() => Generates a SHA-1 hash from a string (one-way hashing).

$user->getEmailForVerification() => Returns the email address used for verification purposes.

$user->hasVerifiedEmail() =>Checks whether the user’s email has already been verified.

Get Yigit Sozer’s stories in your inbox

Join Medium for free to get updates from this writer.

$user->markEmailAsVerified() => Marks the user’s email as verified by setting the verification timestamp.

app/Providers/AppServiceProvider.php

Laravel’s default verification link points to the /web route. For API-based applications, we customize it to work with the /api prefix,Without this verification link would navigates us to laravel web not to Angular(U may not use it if u use Laravel web).

class AppServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
// Create Verivy Link as /api/email/verify/...
VerifyEmail::createUrlUsing(function ($notifiable) {
return URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
});
}
}

routes/api.php

Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail']);

.env

Use Mailtrap during development — no actual emails are sent, you just see them in your inbox. In production, switch to Gmail SMTP or SES.

MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io # Geliştirme için Mailtrap
MAIL_PORT=587
MAIL_USERNAME=your_mailtrap_username
MAIL_PASSWORD="your_mailtrap_password"
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@myProject.com
MAIL_FROM_NAME="MyProject"
APP_URL=http://localhost:8085
Press enter or click to view image in full size
Mailtrap screenshot

Frontend — Angular Side

src/app/models/auth.model.ts

requires_otp?: boolean; // If true, proceed to the OTP screen.
email?: string; // It is stored in the OTP step.

AuthService — login() ve yeni verifyOtp()

login(credentials: LoginRequest): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/login`, credentials).pipe(
tap((response) => {
if (!response.requires_otp) { // added this if block
this.handleAuthSuccess(response); //
}
})
);
}

verifyOtp(email: string, code: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/verify-otp`, { email, code }).pipe(
tap((response) => this.handleAuthSuccess(response))
);
}

Handling the OTP Verification Component

Once the login method returns requires_otp:true we direct the user to a verification screen. The logic in this component, We capture the 6-digits code and the user’s email.

verify() {
this.authService.verifyOtp(this.email, this.otpCode).subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (err) => this.errorMessage = "Invalid or expired code."
});
}

“The full Angular implementation — including the VerifyEmailComponent with its template and styles — is available on GitHub. In this article I’ll focus on the core logic.”

https://github.com/corvoattanoo/mightyinvest

Press enter or click to view image in full size

REGISTRATION FLOW

  1. User fills out the registration form ▼ POST /api/register
  2. event(new Registered($user)) is fired ▼ Laravel automatically sends the verification email
  3. User clicks the link → GET /api/email/verify/{id}/{hash} ▼ Email is verified

LOGIN FLOW (OTP)

  1. Enter email + password → POST /api/login ▼ [Backend] Password correct → Send OTP email → returns requires_otp: true
  2. Angular switches to OTP screen (currentStep signal changes)
  3. Enter 6-digit code → POST /api/verify-otp ▼ [Backend] Code correct + not expired → Generate Sanctum token
  4. Angular saves the token → Redirects to Dashboard

PROTECTED ROUTE FLOW

  • auth:sanctum middleware → Is the token valid?
  • verified middleware → Is the email verified?
  • Both pass → data is returned
  • Email not verified → 403 Forbidden

--

--