Tutorial Login OTP WhatsApp di Laravel (Lengkap + Kode)
Masih kirim OTP lewat SMS? Coba hitung lagi tagihannya. Di Indonesia, satu SMS OTP lewat provider bisa kena Rp350–Rp600 per pesan. Kalau aplikasi kamu punya 10.000 login per bulan, itu Rp3,5–6 juta hanya untuk OTP, pesan yang dibaca 3 detik lalu dilupakan.
Sementara itu, hampir semua user kamu sudah pegang WhatsApp. Tutorial ini akan membahas cara membangun login OTP via WhatsApp di Laravel dari nol: alasan teknis dan biayanya, arsitektur alurnya, sampai kode controller yang bisa langsung kamu jalankan.
Kenapa OTP via WhatsApp, Bukan SMS?
Ada tiga alasan utama yang biasanya jadi pertimbangan developer Indonesia:
1. Biaya jauh lebih murah. Dengan WhatsApp API gateway seperti LoginWA, 1 OTP dihitung 1 pesan dari kuota bulanan. Paket Lite Rp25.000 sudah dapat 3.000 pesan, artinya sekitar Rp8 per OTP. Bandingkan dengan SMS yang Rp350+ per pesan. Untuk 3.000 OTP per bulan:
| Metode | Biaya per OTP | Biaya 3.000 OTP/bulan |
|---|---|---|
| SMS (provider lokal) | ± Rp350–600 | Rp1.050.000 – 1.800.000 |
| WhatsApp via LoginWA Lite | ± Rp8 | Rp25.000 |
| WhatsApp via LoginWA Free | Rp0 (ada watermark kecil) | Rp0 (maks 500 pesan) |
2. Deliverability lebih bisa diandalkan. SMS sering nyangkut di filter operator atau telat datang. Pesan WhatsApp sampai selama nomor user aktif dan online, dan user Indonesia hampir selalu online di WA.
3. UX lebih familiar. User membuka WhatsApp puluhan kali sehari. Notifikasi OTP di WA jauh lebih cepat dilihat dibanding inbox SMS yang sudah penuh promo.
Catatan jujur: WhatsApp gateway unofficial (yang berbasis device/QR scan, bukan WhatsApp Business API resmi Meta) punya risiko nomor pengirim kena pembatasan kalau dipakai serampangan. Gateway yang baik memitigasi ini dengan delay acak dan typing indicator untuk meminimalkan risiko, tapi tidak ada yang bisa jamin 100%. Untuk OTP yang volumenya wajar dan kontennya transaksional, risikonya relatif kecil.
Arsitektur Login OTP WhatsApp
Alurnya sederhana, hanya dua langkah API:
[User] --nomor HP--> [Laravel App] --POST /auth/start--> [LoginWA] --OTP--> [WhatsApp User]
[User] --kode OTP--> [Laravel App] --POST /auth/verify--> [LoginWA] --valid/invalid-->
|
(kalau valid)
v
firstOrCreate user by phone --> Auth::login() --> session
Yang penting dipahami:
- Server LoginWA yang generate dan validasi OTP, bukan aplikasi kamu. Kamu tidak perlu menyimpan kode OTP di database sendiri, cukup simpan
session_idyang dikembalikan endpointstart, lalu kirim balik bersama kode yang diketik user ke endpointverify. - Identitas user = nomor HP. Tidak ada password. Tabel
userscukup punya kolomphoneyang unik. - Rate limiting wajib di sisi Laravel supaya endpoint kirim OTP tidak dijadikan mainan (dan kuota pesan kamu tidak terkuras bot).
Persiapan
- Daftar di loginwa.com/auth/otp (pakai nomor WA, tanpa kartu kredit, langsung dapat 500 pesan gratis).
- Buat app di dashboard, scan QR untuk menghubungkan device WhatsApp pengirim.
- Salin API key, taruh di
.env:
LOGINWA_API_KEY=lw_xxxxxxxxxxxx
LOGINWA_BASE_URL=https://api.loginwa.com
- Daftarkan di
config/services.php:
'loginwa' => [
'key' => env('LOGINWA_API_KEY'),
'base_url' => env('LOGINWA_BASE_URL', 'https://api.loginwa.com'),
],
Migrasi: Tabel Users Berbasis Nomor HP
Kalau project baru, ubah migration users supaya phone jadi identitas utama:
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('phone', 20)->unique();
$table->string('name')->nullable();
$table->timestamp('phone_verified_at')->nullable();
$table->rememberToken();
$table->timestamps();
});
Project lama? Cukup tambahkan kolom phone unik dan nullable-kan password.
Service Class: Pembungkus API LoginWA
Supaya rapi, bungkus pemanggilan API di satu class. Buat app/Services/LoginwaOtp.php:
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class LoginwaOtp
{
protected function client(): PendingRequest
{
return Http::baseUrl(config('services.loginwa.base_url'))
->withToken(config('services.loginwa.key'))
->acceptJson()
->timeout(15);
}
/**
* Kirim OTP ke nomor WhatsApp. Mengembalikan session_id.
*/
public function start(string $phone): string
{
$response = $this->client()
->post('/api/v1/auth/start', ['phone' => $phone])
->throw();
return $response->json('session_id');
}
/**
* Verifikasi kode OTP. Mengembalikan true jika valid.
*/
public function verify(string $sessionId, string $otpCode): bool
{
$response = $this->client()->post('/api/v1/auth/verify', [
'session_id' => $sessionId,
'otp_code' => $otpCode,
]);
return $response->successful();
}
}
Controller: Dua Endpoint, Selesai
Buat app/Http/Controllers/Auth/OtpLoginController.php:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\LoginwaOtp;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class OtpLoginController extends Controller
{
public function __construct(protected LoginwaOtp $otp)
{
}
/**
* Langkah 1: user submit nomor HP, kita kirim OTP via WhatsApp.
*/
public function send(Request $request)
{
$request->validate([
'phone' => ['required', 'regex:/^628[0-9]{8,12}$/'],
], [
'phone.regex' => 'Gunakan format 628xxxxxxxxxx tanpa tanda + atau spasi.',
]);
try {
$sessionId = $this->otp->start($request->phone);
} catch (\Throwable $e) {
report($e);
throw ValidationException::withMessages([
'phone' => 'Gagal mengirim OTP. Coba lagi sebentar lagi.',
]);
}
// Simpan di session Laravel, bukan dikirim ke browser
$request->session()->put('otp.session_id', $sessionId);
$request->session()->put('otp.phone', $request->phone);
return redirect()->route('otp.form')
->with('status', 'Kode OTP sudah dikirim ke WhatsApp kamu.');
}
/**
* Langkah 2: user submit kode OTP, kita verifikasi lalu login.
*/
public function verify(Request $request)
{
$request->validate([
'otp_code' => ['required', 'digits:6'],
]);
$sessionId = $request->session()->get('otp.session_id');
$phone = $request->session()->get('otp.phone');
if (! $sessionId || ! $phone) {
return redirect()->route('login')
->withErrors(['otp_code' => 'Sesi OTP kedaluwarsa. Minta kode baru.']);
}
if (! $this->otp->verify($sessionId, $request->otp_code)) {
throw ValidationException::withMessages([
'otp_code' => 'Kode OTP salah atau sudah kedaluwarsa.',
]);
}
// OTP valid: cari atau buat user berdasarkan nomor HP
$user = User::firstOrCreate(
['phone' => $phone],
['phone_verified_at' => now()]
);
Auth::login($user, remember: true);
$request->session()->forget(['otp.session_id', 'otp.phone']);
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
}
Perhatikan dua hal penting di kode ini:
session_iddisimpan di session server-side, tidak pernah dikirim ke browser dalam bentuk hidden input. Ini mencegah user menukar session milik nomor lain.firstOrCreatemembuat alur registrasi dan login jadi satu: user baru otomatis terdaftar saat pertama kali verifikasi berhasil.
Routes + Rate Limiting
Ini bagian yang sering dilupakan. Tanpa rate limit, satu skrip iseng bisa membakar kuota pesan kamu dalam hitungan menit. Laravel sudah punya throttle middleware bawaan:
use App\Http\Controllers\Auth\OtpLoginController;
Route::middleware('guest')->group(function () {
Route::post('/otp/send', [OtpLoginController::class, 'send'])
->middleware('throttle:otp-send')
->name('otp.send');
Route::post('/otp/verify', [OtpLoginController::class, 'verify'])
->middleware('throttle:otp-verify')
->name('otp.verify');
});
Lalu definisikan limiter-nya di AppServiceProvider::boot() (atau bootstrap/app.php di Laravel 11+):
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('otp-send', function ($request) {
return [
// maksimal 3 kirim OTP per nomor per 10 menit
Limit::perMinutes(10, 3)->by('send:'.$request->input('phone')),
// dan maksimal 10 per IP per jam (jaga-jaga bot ganti-ganti nomor)
Limit::perHour(10)->by('send-ip:'.$request->ip()),
];
});
RateLimiter::for('otp-verify', function ($request) {
// maksimal 5 percobaan verifikasi per sesi per 10 menit
return Limit::perMinutes(10, 5)
->by('verify:'.$request->session()->getId());
});
Aturan praktisnya: limit pengiriman by nomor HP (mencegah spam ke satu korban), limit by IP (mencegah bot menyebar), dan limit verifikasi (mencegah brute force kode 6 digit).
Tips UX yang Bikin Conversion Naik
Kode jalan itu baru setengah cerita. Beberapa detail kecil yang berpengaruh besar:
- Auto-format input nomor. Terima
08xx,+628xx,628xx, normalisasi ke628xxdi backend sebelum validasi. Jangan paksa user mikir format. - Tombol "Kirim ulang" dengan countdown. Tampilkan timer 60 detik sebelum tombol resend aktif. Ini selaras dengan rate limit di server dan mengurangi klik panik.
- Input OTP pakai
inputmode="numeric"danautocomplete="one-time-code". Keyboard angka langsung muncul di HP. - Beri tahu user untuk cek WhatsApp, bukan SMS. Kedengarannya sepele, tapi user yang terbiasa SMS OTP akan bengong menunggu SMS yang tidak pernah datang.
- Sediakan fallback. Kalau user tidak punya WA di nomor tersebut, minimal tampilkan kontak support. Kamu juga bisa cek dulu apakah nomor terdaftar di WhatsApp lewat endpoint
POST /api/v1/numbers/checksebelum mengirim OTP, lebih hemat kuota dan error message-nya bisa lebih jelas.
Berapa Biayanya di Produksi?
Hitungan kasar berdasarkan harga LoginWA:
- Tahap development / side project: paket Free, Rp0, 500 pesan/bulan (ada watermark kecil di pesan).
- Aplikasi kecil, ± 100 login/hari: paket Lite Rp25.000 untuk 3.000 pesan.
- Aplikasi menengah, ± 500 login/hari: paket Regular Rp65.000 untuk 15.000 pesan.
- Skala lebih besar: Business Rp149.000 untuk 60.000 pesan, plus IP whitelist untuk keamanan API key.
Sebagai pembanding, gateway lokal lain seperti Fonnte (mulai Rp25.000/1.000 pesan) atau Wablas (Rp22.000–139.000/bulan) juga bisa dipakai untuk pola yang mirip, bedanya, di LoginWA alur OTP sudah jadi endpoint khusus (/auth/start dan /auth/verify), jadi kamu tidak perlu generate, simpan, dan expire kode OTP sendiri.
Checklist Sebelum Naik ke Produksi
- Rate limit aktif di endpoint send dan verify
-
session_idhanya hidup di session server, bukan di HTML/JS - Nomor HP dinormalisasi ke format
628xxsebelum disimpan - Error API ditangkap dan dilaporkan (
report($e)), bukan dibiarkan jadi 500 -
session()->regenerate()dipanggil setelah login sukses - Device WhatsApp pengirim dipantau statusnya (LoginWA punya webhook dengan signature HMAC-SHA256 untuk event device disconnect, jadi kamu bisa dapat alert sebelum user komplain)
Penutup
Login OTP via WhatsApp di Laravel ternyata tidak rumit: satu service class, satu controller dengan dua method, dan rate limiter bawaan Laravel. Yang paling terasa adalah selisih biayanya, dari ratusan rupiah per OTP via SMS jadi hitungan rupiah saja via WA.
Kalau mau coba sendiri, daftar gratis di loginwa.com/auth/otp, dapat 500 pesan per bulan tanpa kartu kredit, cukup untuk development sampai siap launching. Dokumentasi lengkap endpoint-nya ada di /docs, dan kalau butuh fitur lain seperti broadcast atau auto-reply, lihat halaman fitur dan pricing.