Passwordless OTP Login in Laravel with Fast2SMS (Complete Flow, With Tests)
Most Indian apps don’t log people in with a password. They ask for a mobile number, send a 6-digit code, and let the user in. This post builds that flow end to end in Laravel using laravel-fast2sms: the routes, the OTP storage, the verification, rate limiting, and a test suite that never sends a real SMS.
By the end you’ll have a login that works in local development without spending a single credit, and is safe to put in front of real users.

What we’re building
- The user enters a mobile number.
- We generate a 6-digit code, store a hash of it for 5 minutes, and send it via the Fast2SMS OTP route.
- The user enters the code.
- We verify it, log them in, and throw the code away.
Along the way we’ll handle the things that go wrong in production: people tapping “resend” five times, bots hammering the endpoint, and codes that should have expired.
Requirements
- PHP 8.3+ and Laravel 11, 12 or 13
itxshakil/laravel-fast2sms2.2.1 or newer- A Fast2SMS account and API key (free to sign up)
- A
userstable with aphonecolumn (we’ll add it)
Step 1: Install and configure
composer require itxshakil/laravel-fast2sms
php artisan vendor:publish --tag=fast2sms-config
Add to .env:
FAST2SMS_API_KEY=your_api_key_here
FAST2SMS_DRIVER=log
That second line matters. With FAST2SMS_DRIVER=log, every send is written to storage/logs/laravel.log instead of hitting the API. You can build and click through the whole flow locally, read the OTP from the log, and never burn a credit. Switch it to api when you deploy.
Step 2: Add a phone column
We’ll let users sign up by phone alone, so password becomes nullable.
php artisan make:migration add_phone_to_users_table
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone', 10)->unique()->nullable()->after('email');
$table->string('password')->nullable()->change();
});
}
Add phone to $fillable on the User model, then run php artisan migrate.
Step 3: The OTP service
Keep the OTP logic out of the controller. This class generates, stores, sends, and verifies codes.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Shakil\Fast2sms\Facades\Fast2sms;
class OtpService
{
private const TTL_SECONDS = 300; // code lives for 5 minutes
private const MAX_ATTEMPTS = 5; // wrong guesses before the code is invalidated
private const RESEND_COOLDOWN = 60; // seconds between sends to the same number
public function send(string $phone): void
{
if (Cache::has($this->cooldownKey($phone))) {
return; // silently ignore rapid resends; the first code is still valid
}
$code = (string) random_int(100000, 999999);
Cache::put($this->codeKey($phone), Hash::make($code), self::TTL_SECONDS);
Cache::put($this->attemptsKey($phone), 0, self::TTL_SECONDS);
Cache::put($this->cooldownKey($phone), true, self::RESEND_COOLDOWN);
Fast2sms::otp(numbers: $phone, otpValue: $code);
}
public function verify(string $phone, string $code): bool
{
$hash = Cache::get($this->codeKey($phone));
if ($hash === null) {
return false; // expired or never sent
}
$attempts = Cache::increment($this->attemptsKey($phone));
if ($attempts > self::MAX_ATTEMPTS) {
$this->forget($phone);
return false;
}
if (! Hash::check($code, $hash)) {
return false;
}
$this->forget($phone);
return true;
}
private function forget(string $phone): void
{
Cache::forget($this->codeKey($phone));
Cache::forget($this->attemptsKey($phone));
}
private function codeKey(string $phone): string
{
return "otp:code:{$phone}";
}
private function attemptsKey(string $phone): string
{
return "otp:attempts:{$phone}";
}
private function cooldownKey(string $phone): string
{
return "otp:cooldown:{$phone}";
}
}
A few deliberate choices here:
- The code is hashed before it’s cached. If someone gets read access to Redis, they still can’t log in as your users.
random_int, notrandormt_rand. OTPs must be unpredictable.random_intis cryptographically secure.- Five wrong guesses kills the code. A 6-digit code has a million combinations. Without an attempt limit, a script can brute-force it inside the 5-minute window.
- A 60-second resend cooldown. Users double-tap. Bots triple-tap. Each tap would otherwise cost you a credit.
Why the Fast2SMS OTP route and not a normal SMS? The otp route only accepts a numeric code and delivers it through Fast2SMS’s own registered template, so you don’t need your own DLT-approved template to get started. That’s the fastest path from zero to a working login in India.
Step 4: Validation
Fast2SMS only delivers to Indian mobile numbers, and the package ships a rule for exactly that. It accepts 10 digits starting with 6 to 9.
php artisan make:request SendOtpRequest
php artisan make:request VerifyOtpRequest
// app/Http/Requests/SendOtpRequest.php
use Shakil\Fast2sms\Rules\Fast2smsPhone;
public function rules(): array
{
return [
'phone' => ['required', 'string', new Fast2smsPhone],
];
}
// app/Http/Requests/VerifyOtpRequest.php
use Shakil\Fast2sms\Rules\Fast2smsPhone;
public function rules(): array
{
return [
'phone' => ['required', 'string', new Fast2smsPhone],
'code' => ['required', 'digits:6'],
];
}
Step 5: The controller
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\SendOtpRequest;
use App\Http\Requests\VerifyOtpRequest;
use App\Models\User;
use App\Services\OtpService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class OtpLoginController extends Controller
{
public function __construct(private readonly OtpService $otp) {}
public function showPhoneForm(): View
{
return view('auth.otp.phone');
}
public function sendCode(SendOtpRequest $request): RedirectResponse
{
$phone = $request->validated('phone');
$this->otp->send($phone);
$request->session()->put('otp_phone', $phone);
return redirect()->route('otp.verify.form')
->with('status', 'We sent a 6-digit code to your number.');
}
public function showVerifyForm(Request $request): View|RedirectResponse
{
if (! $request->session()->has('otp_phone')) {
return redirect()->route('otp.phone.form');
}
return view('auth.otp.verify', ['phone' => $request->session()->get('otp_phone')]);
}
public function verifyCode(VerifyOtpRequest $request): RedirectResponse
{
$phone = $request->validated('phone');
if (! $this->otp->verify($phone, $request->validated('code'))) {
return back()->withErrors(['code' => 'That code is wrong or has expired.']);
}
$user = User::firstOrCreate(['phone' => $phone], ['name' => $phone]);
Auth::login($user);
$request->session()->regenerate();
$request->session()->forget('otp_phone');
return redirect()->intended('/dashboard');
}
}
Note the one error message for both “wrong code” and “expired code”. Telling an attacker which one happened helps them; it doesn’t help a real user.
Step 6: Routes with rate limiting
Two limiters: one per phone number, so a single number can’t be flooded, and one per IP, so a bot can’t rotate numbers.
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('otp-send', fn (Request $request) => [
Limit::perMinute(3)->by((string) $request->input('phone')),
Limit::perMinute(10)->by($request->ip()),
]);
RateLimiter::for('otp-verify', fn (Request $request) => [
Limit::perMinute(10)->by((string) $request->input('phone')),
]);
}
// routes/web.php
use App\Http\Controllers\Auth\OtpLoginController;
Route::middleware('guest')->group(function () {
Route::get('/login', [OtpLoginController::class, 'showPhoneForm'])->name('otp.phone.form');
Route::post('/login/send', [OtpLoginController::class, 'sendCode'])
->middleware('throttle:otp-send')
->name('otp.send');
Route::get('/login/verify', [OtpLoginController::class, 'showVerifyForm'])->name('otp.verify.form');
Route::post('/login/verify', [OtpLoginController::class, 'verifyCode'])
->middleware('throttle:otp-verify')
->name('otp.verify');
});
Step 7: The views
Two tiny Blade forms. Style them however you like.
{{-- resources/views/auth/otp/phone.blade.php --}}
<form method="POST" action="{{ route('otp.send') }}">
@csrf
<label>Mobile number</label>
<input name="phone" inputmode="numeric" maxlength="10" value="{{ old('phone') }}" required>
@error('phone') <p>{{ $message }}</p> @enderror
<button type="submit">Send code</button>
</form>
{{-- resources/views/auth/otp/verify.blade.php --}}
@if (session('status')) <p>{{ session('status') }}</p> @endif
<form method="POST" action="{{ route('otp.verify') }}">
@csrf
<input type="hidden" name="phone" value="{{ $phone }}">
<label>Enter the 6-digit code</label>
<input name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required>
@error('code') <p>{{ $message }}</p> @enderror
<button type="submit">Log in</button>
</form>
<form method="POST" action="{{ route('otp.send') }}">
@csrf
<input type="hidden" name="phone" value="{{ $phone }}">
<button type="submit">Resend code</button>
</form>
autocomplete="one-time-code" lets iOS and Android offer the code from the incoming SMS with one tap.
Run it now with FAST2SMS_DRIVER=log, submit your number, and grep the log:
tail -f storage/logs/laravel.log | grep -i otp
Step 8: Test it without sending an SMS
This is where the package earns its keep. Fast2sms::fake() records every send, and you can pull the code straight out of it.
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Shakil\Fast2sms\Enums\SmsRoute;
use Shakil\Fast2sms\Facades\Fast2sms;
use Tests\TestCase;
class OtpLoginTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_log_in_with_a_valid_code(): void
{
Fast2sms::fake();
$this->post(route('otp.send'), ['phone' => '9876543210'])
->assertRedirect(route('otp.verify.form'));
Fast2sms::assertSmsSentTo('9876543210');
Fast2sms::assertSmsSentWithRoute(SmsRoute::OTP);
// The OTP route sends the code as the template variable.
$code = Fast2sms::sentSms()[0]->parameters->variablesValues;
$this->post(route('otp.verify'), ['phone' => '9876543210', 'code' => $code])
->assertRedirect('/dashboard');
$this->assertAuthenticated();
$this->assertDatabaseHas('users', ['phone' => '9876543210']);
}
public function test_wrong_code_is_rejected(): void
{
Fast2sms::fake();
$this->post(route('otp.send'), ['phone' => '9876543210']);
$this->post(route('otp.verify'), ['phone' => '9876543210', 'code' => '000000'])
->assertSessionHasErrors('code');
$this->assertGuest();
}
public function test_invalid_phone_never_sends(): void
{
Fast2sms::fake();
$this->post(route('otp.send'), ['phone' => '12345'])
->assertSessionHasErrors('phone');
Fast2sms::assertNothingSent();
}
public function test_resend_inside_cooldown_does_not_send_again(): void
{
Fast2sms::fake();
$this->post(route('otp.send'), ['phone' => '9876543210']);
$this->post(route('otp.send'), ['phone' => '9876543210']);
Fast2sms::assertSmsSentCount(1);
}
}
Four tests, zero network calls, and they run in well under a second.
Going to production
Flip the driver and turn on the guards the package gives you for free:
FAST2SMS_DRIVER=api
FAST2SMS_QUEUE_ENABLED=true
FAST2SMS_DEDUP_ENABLED=true
FAST2SMS_DEDUP_TTL=60
FAST2SMS_BALANCE_GATE=true
FAST2SMS_BALANCE_THRESHOLD=100
- Queue so the HTTP request to Fast2SMS doesn’t sit in your user’s page load. The controller code doesn’t change; the package dispatches a job.
- Dedup guard as a second line of defence behind the cooldown. An identical send to the same number within 60 seconds throws
DuplicateSendExceptioninstead of costing a credit. - Balance gate so you find out your wallet is empty from a
LowBalanceDetectedevent, not from a support ticket.
And a short checklist before launch:
- Never log the OTP in production. The
logdriver is for local only. - Keep the TTL short. Five minutes is plenty.
- Run
php artisan fast2sms:balance --threshold=500from a scheduled task and alert on it. - If you want to know whether the code actually reached the handset, enable the package’s delivery-status webhooks and listen for
MessageFailed.
Wrapping up
You now have a passwordless login that hashes and expires codes, limits guesses and resends, rate-limits by phone and IP, and is fully tested without a single real SMS.
The full package, with WhatsApp, DLT templates, notification channels and delivery webhooks, is on GitHub and Packagist. If it saves you an afternoon, a star helps other Laravel developers in India find it.
Questions or something you’d do differently? Tell me in the comments.