Laravel Developer Guide

Laravel Phone Verification via NepalOTP

Learn the cleanest, most idiomatic way to implement OTP-based phone verification in your Laravel application using custom validation rules, service classes, and the native HTTP Client.

Laravel provides unparalleled scaffolding for email-based authentication, but verifying phone numbers requires orchestrating external API calls to a reliable SMS gateway. Wiring this logic directly into your controllers creates bloated, untestable code.

In this advanced guide, we will architect a robust phone verification flow. We will build a dedicated Service Class, utilize the powerful Illuminate\Support\Facades\Http facade, and create custom validation rules to ensure only valid Nepali phone numbers hit your database.


1. Configuration & Setup

First, secure your API credentials. Never hardcode API keys in your classes. Add your NepalOTP keys to your environment file:

.env
NEPALOTP_API_KEY=notp_live_sk_your_secret_key_here

Next, map the environment variable in your config/services.php file. This allows Laravel to cache the configuration for production performance and makes it available globally via the config() helper.

config/services.php
return [
    // ... mailgun, postmark, etc.
    
    'nepalotp' => [
        'key' => env('NEPALOTP_API_KEY'),
    ],
];

2. Creating the Service Class

To adhere to the Single Responsibility Principle (SRP), we will abstract all HTTP communication with NepalOTP into a dedicated service. This makes your application easily testable—you can mock this service during PHPUnit/Pest testing without actually firing network requests.

app/Services/NepalOtpService.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;

class NepalOtpService
{
    protected string $baseUrl = 'https://api.nepalotp.com/v1';

    /**
     * Configure the base HTTP client with authentication and timeouts.
     */
    protected function client(): PendingRequest
    {
        return Http::withToken(config('services.nepalotp.key'))
            ->acceptJson()
            ->timeout(5); // Prevent hanging requests if network fails
    }

    /**
     * Dispatch an OTP to a Nepali phone number.
     * 
     * @throws RequestException
     */
    public function sendOtp(string $phone): array
    {
        return $this->client()
            ->post("{$this->baseUrl}/otp/send", [
                'phone' => $phone
            ])
            ->throw()
            ->json();
    }

    /**
     * Validate an OTP code against its corresponding ID.
     * 
     * @throws RequestException
     */
    public function verifyOtp(string $otpId, string $code): array
    {
        return $this->client()
            ->post("{$this->baseUrl}/otp/verify", [
                'id' => $otpId,
                'code' => $code
            ])
            ->throw()
            ->json();
    }
}

3. Custom Validation Rule

Before we hit the API, we must ensure the phone number conforms to the E.164 standard required by telecom networks. Let's create a reusable Laravel Rule object.

php artisan make:rule NepaliPhone
app/Rules/NepaliPhone.php
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class NepaliPhone implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // Ensures the format strictly matches +977 followed by 10 digits
        if (!preg_match('/^\+977\d{10}$/', $value)) {
            $fail('The :attribute must be a valid Nepali phone number starting with +977.');
        }
    }
}

4. Orchestrating in the Controller

Now, we orchestrate the entire flow. We use Laravel's powerful Dependency Injection (IoC container) to automatically instantiate our NepalOtpService within the controller methods.

app/Http/Controllers/Auth/PhoneVerificationController.php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Rules\NepaliPhone;
use App\Services\NepalOtpService;
use Illuminate\Http\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Auth;

class PhoneVerificationController extends Controller
{
    /**
     * Handle the incoming request to dispatch an OTP.
     */
    public function store(Request $request, NepalOtpService $otpService)
    {
        // 1. Validate Input
        $validated = $request->validate([
            'phone' => ['required', 'string', new NepaliPhone]
        ]);
        
        try {
            // 2. Dispatch OTP
            $result = $otpService->sendOtp($validated['phone']);
            
            // 3. Store the OTP ID in the session temporarily
            session()->put('verification_otp_id', $result['data']['otp_id']);
            session()->put('verification_phone', $validated['phone']);
            
            return redirect()->route('verification.notice')
                           ->with('status', 'Verification code sent!');
            
        } catch (RequestException $e) {
            // 4. Safely extract API error messages (e.g. Rate Limits)
            $errorMessage = $e->response->json('message', 'Failed to send OTP. Please try again.');
            
            return back()->withErrors(['phone' => $errorMessage])->withInput();
        }
    }

    /**
     * Verify the code submitted by the user.
     */
    public function update(Request $request, NepalOtpService $otpService)
    {
        $request->validate([
            'code' => ['required', 'numeric', 'digits:6']
        ]);

        $otpId = session('verification_otp_id');

        if (!$otpId) {
            return redirect()->route('register')->withErrors('Session expired. Please restart registration.');
        }

        try {
            $otpService->verifyOtp($otpId, $request->code);
            
            // Success! The API returned 200 OK. 
            // Create user, clear session, and login.
            
            /* Example:
            $user = User::create(['phone' => session('verification_phone')]);
            Auth::login($user);
            */

            session()->forget(['verification_otp_id', 'verification_phone']);
            return redirect()->route('dashboard');

        } catch (RequestException $e) {
            // Handle invalid codes, expirations, or max-attempt lockouts
            $errorMessage = $e->response->json('error.message', 'Invalid verification code.');
            
            return back()->withErrors(['code' => $errorMessage]);
        }
    }
}

Testing & Mocking

Because we abstracted the logic into a Service Class, testing our controller in Pest or PHPUnit is incredibly easy. We can utilize Laravel's powerful Http::fake() method to simulate API responses without hitting the actual NepalOTP servers.

tests/Feature/PhoneVerificationTest.php
use Illuminate\Support\Facades\Http;

test('it redirects to verification notice on successful otp dispatch', function () {
    // Fake the NepalOTP endpoint to always return success
    Http::fake([
        'api.nepalotp.com/v1/otp/send' => Http::response([
            'success' => true,
            'data' => ['otp_id' => 'test_id_123']
        ], 200)
    ]);

    $response = $this->post('/register/phone', [
        'phone' => '+9779812345678'
    ]);

    $response->assertRedirect(route('verification.notice'));
    $response->assertSessionHas('verification_otp_id', 'test_id_123');
});

Start integrating today

Get your sandbox API keys instantly and integrate our endpoints into your Laravel application in minutes.

Create Developer Account