Spam submissions can ruin your contact form's effectiveness. This guide shows you how to implement OTP verification via SMS in Laravel to ensure only genuine users can submit forms while blocking bots and spammers.
For Indian phone numbers, you need an approved sender ID from telecom providers:
Visit websites of Tata, Jio, Airtel, or BSNL
Apply for a sender ID (6-character alphanumeric)
Submit required business documents for verification
Approval typically takes 3-7 working days
Once sender ID is approved, register your OTP template with the provider. Example:
Your OTP for Vfix Technology is ##var##. Don't share with anyone else.
Templates must follow strict guidelines - no promotional content, only transactional.
We'll use MSG91 as our SMS gateway. Link your sender ID provider (Tata/Airtel/etc.) with MSG91 by:
Providing MSG91 with your sender ID credentials
Completing KYC documentation if required
Create account at MSG91
Add your approved sender ID
Upload and approve your OTP template
Note your:
API key
Template ID
Add the official package to your Laravel project:
composer require craftsys/msg91-laravel
Msg91_KEY=*******************
In config/app.php
, add to providers array:
'providers' => [
// Other service providers...
Craftsys\Msg91\Msg91LaravelServiceProvider::class,
],
In same config/app.php
, add to aliases:
'aliases' => [
// other aliases here
'Msg91' => Craftsys\Msg91\Facade\Msg91::class,
],
Create HomeController
with these key methods:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Mail;
use Msg91;
class HomeController extends Controller
{
public function index()
{
return view('home');
}
public function sendOtp(Request $request)
{
$request->validate([
'name' => 'required|max:75',
'email' => 'required|email|max:40',
'phone' => 'required|digits:10',
'message' => 'required|string|max:255',
]);
// Prevent duplicate submissions within 24h
if (Cookie::has('contact_form_submitted')) {
return response()->make("
<script>
alert('Your form has already been submitted. You can only submit the form once every 24 hours.');
window.history.back();
</script>
");
}
// Block after 3 OTP attempts
if (session('resend_count', 0) >= 3) {
Cookie::queue('otp_blocked', true, 1440);
return response()->make("
<script>
alert('You have exceeded the maximum OTP requests. Try again in 24 hours.');
window.history.back();
</script>
");
}
$otp = rand(1000, 9999);
session([
'contact_form_data' => $request->only('name', 'email', 'phone', 'message'),
'otp_phone' => $request->phone,
'otp' => $otp,
'resend_count' => session('resend_count', 0),
]);
try {
$response = Msg91::otp($otp)
->to('91' . $request->phone)
->template('YOUR_TEMPLATE_ID')
->send();
return redirect()->route('otp.form')->with('success', 'OTP sent successfully.');
} catch (\Exception $e) {
return back()->with('error', 'Failed to send OTP: ' . $e->getMessage());
}
}
// Additional methods (verifyOtp, showOtpForm, resendOtp, thanks)
// as shown in your original code
}
index.blade.php
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contact form</title>
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<section class="py-10">
<div class="container mx-auto px-4">
<div class="flex justify-center">
<div class="w-full max-w-xl bg-white rounded-lg shadow p-6">
<form id="contactForm" method="post" action="{{ route('send.otp') }}">
@csrf
<div class="grid md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="name">
Full name <span class="text-red-500 font-bold">*</span>
</label>
<input
class="border border-gray-300 rounded w-full py-2 px-3 text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
name="name" type="text" placeholder="Your full name"
value="{{ old('name') }}" required>
@error('name')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="email">
Email <span class="text-red-500 font-bold">*</span>
</label>
<input
class="border border-gray-300 rounded w-full py-2 px-3 text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
name="email" type="email" placeholder="Your email"
value="{{ old('email') }}" required>
@error('email')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="phone">
Phone no. <span class="text-red-500 text-xs">[Only 10 digits no space]*</span>
</label>
<input
class="border border-gray-300 rounded w-full py-2 px-3 text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
name="phone" type="number" placeholder="Your phone number"
value="{{ old('phone') }}" required>
@error('phone')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="message">
Explain your requirement <span class="text-red-500 font-bold">*</span>
</label>
<textarea
class="border border-gray-300 rounded w-full py-2 px-3 text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
name="message" id="message" rows="4"
placeholder="Please share anything that will help prepare for our meeting...."
required>{{ old('message') }}</textarea>
@error('message')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
<div class="mt-6">
<button id="submit-btn"
class="w-full bg-green-600 hover:bg-green-700 text-white text-lg font-semibold py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-green-500">
>> SUBMIT
</button>
</div>
</form>
</div>
</div>
</div>
</section>
</body>
</html>
otp-form.blade.php
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verify OTP</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
<style>
.otp-input {
letter-spacing: 10px;
font-size: 1.5rem;
text-align: center;
}
#resendBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body class="bg-light">
<section>
<div class="container">
<div class="row min-vh-100 align-items-center justify-content-center py-5">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- Status Messages -->
@if ($errors->any())
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<strong>Error!</strong> Please fix the following:
<ul class="mb-0 mt-2">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
@endif
@if (session('error'))
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<strong>Error:</strong> {{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
@endif
@if (session('success'))
<div class="alert alert-success alert-dismissible fade show mb-4" role="alert">
<strong>Success:</strong> {{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
@endif
<div class="text-center mb-4">
<h4 class="fw-bold">Verify OTP</h4>
<p class="text-muted">Enter the 4-digit code sent to
{{ session('otp_phone') ?? 'your phone' }}</p>
</div>
<!-- OTP Verification Form -->
<form id="verifyOtpForm" method="POST" action="{{ route('verify.otp') }}"
autocomplete="off">
@csrf
<div class="mb-4">
<label for="otp" class="form-label">OTP Code</label>
<input type="text" name="otp" class="form-control form-control-lg otp-input"
id="otp" placeholder="••••" required maxlength="4" pattern="\d{4}"
inputmode="numeric" oninput="this.value = this.value.replace(/[^0-9]/g, '')">
</div>
<input type="hidden" name="phone" value="{{ session('otp_phone') }}">
<button id="verifyBtn" type="submit" class="btn btn-primary btn-lg w-100 py-2">
Verify & Continue
</button>
</form>
<!-- Resend OTP Section -->
<div class="text-center mt-4 pt-3">
@if (session('resend_count', 0) < 3)
<p class="mb-2">
Didn't receive code?
<span id="timer" class="text-muted">Resend in 30s</span>
</p>
<form id="resendOtpForm" method="POST" action="{{ route('resend.otp') }}">
@csrf
<input type="hidden" name="phone" value="{{ session('otp_phone') }}">
<button id="resendBtn" type="button" class="btn btn-link p-0" disabled>
Resend OTP
</button>
</form>
@else
<p class="text-danger">
You have exceeded the maximum OTP requests. Try again in 24 hours.
</p>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO" crossorigin="anonymous">
</script>
<script>
// Resend OTP Timer
let timer = 30;
const timerElement = document.getElementById('timer');
const resendBtn = document.getElementById('resendBtn');
if (timerElement && resendBtn) {
const countdown = setInterval(() => {
if (timer <= 0) {
clearInterval(countdown);
timerElement.textContent = '';
resendBtn.disabled = false;
resendBtn.addEventListener('click', function() {
document.getElementById('resendOtpForm').submit();
});
} else {
timerElement.textContent = `Resend in ${timer--}s`;
}
}, 1000);
}
</script>
<script>
// Prevent double click on "Verify & Continue" button
const form = document.getElementById('verifyOtpForm');
const verifyBtn = document.getElementById('verifyBtn');
if (form && verifyBtn) {
form.addEventListener('submit', function(e) {
verifyBtn.disabled = true;
verifyBtn.innerText = 'Verifying...';
});
}
</script>
</body>
</html>
thanks.blade.php
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thanks</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
</head>
<body>
<section class="bg-white">
<div class="container">
<div class="row d-flex align-items-center justify-content-center text-center">
<div class="col-md-8">
<i style="font-size: 70px;" class="bi bi-check2-circle text-success"></i>
<h1>Thanks, you are all set!</h1>
<p>
We received your message, one of our team mate will contact you soon. Thank
You!
</p>
<a href="/" class="btn btn-success py-0 mt-3 fs-3">Back to Home</a>
</div>
</div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO" crossorigin="anonymous">
</script>
</body>
</html>
Add these routes in routes/web.php
:
Route::get('/', [App\Http\Controllers\HomeController::class, 'index']);
Route::get('/contact/verify/otp', [App\Http\Controllers\HomeController::class, 'showOtpForm'])->name('otp.form');
Route::post('/contact/send-otp', [App\Http\Controllers\HomeController::class, 'sendOtp'])->name('send.otp');
Route::post('/contact/verify-otp', [App\Http\Controllers\HomeController::class, 'verifyOtp'])->name('verify.otp');
Route::post('/contact/resend-otp', [App\Http\Controllers\HomeController::class, 'resendOtp'])->name('resend.otp');
Route::get('thanks', [App\Http\Controllers\HomeController::class, 'thanks'])->name('thanks');
24-hour submission limit - Prevents duplicate submissions
OTP attempt limiting - Blocks after 3 failed attempts
Session validation - Ensures OTP matches original request
This implementation provides robust spam protection while maintaining user experience. The OTP verification adds a critical layer of security to ensure only genuine users with valid phone numbers can contact you. Customize the templates and messaging to match your brand voice.
For production use, consider adding:
Rate limiting middleware
CAPTCHA fallback
SMS delivery failure handling
Analytics integration