Passwordless OTP Login in Laravel with Fast2SMS (Complete Flow, With Tests)

Passwordless OTP Login in Laravel with Fast2SMS (Complete Flow, With Tests)

Reading Time: 5 minutes

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.

Passwordless OTP Login in Laravel with Fast2SMS (Complete Flow, With Tests)

What we’re building

  1. The user enters a mobile number.
  2. We generate a 6-digit code, store a hash of it for 5 minutes, and send it via the Fast2SMS OTP route.
  3. The user enters the code.
  4. 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-fast2sms 2.2.1 or newer
  • A Fast2SMS account and API key (free to sign up)
  • A users table with a phone column (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, not rand or mt_rand. OTPs must be unpredictable. random_int is 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 DuplicateSendException instead of costing a credit.
  • Balance gate so you find out your wallet is empty from a LowBalanceDetected event, not from a support ticket.

And a short checklist before launch:

  • Never log the OTP in production. The log driver is for local only.
  • Keep the TTL short. Five minutes is plenty.
  • Run php artisan fast2sms:balance --threshold=500 from 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.

Passwordless OTP Login in Laravel with Fast2SMS (Complete Flow, With Tests)

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.