Banyak aplikasi Laravel butuh satu langkah sederhana sebelum akun End User dianggap valid: pastikan nomor yang dimasukkan memang bisa menerima kode OTP. Di tutorial ini, backend Laravel akan memanggil API OTP.ID untuk mengirim kode, menyimpan otp_id, lalu memverifikasi kode yang diketik End User.
Fokusnya bukan membuat UI lengkap. Fokusnya adalah alur backend yang aman dan cukup jelas untuk dijadikan fondasi register, login OTP, atau verifikasi nomor HP.
Kapan integrasi ini dipakai?
Pakai pola ini saat aplikasi Laravel milik Client perlu memverifikasi nomor End User. Contohnya:
- setelah End User mengisi form register;
- sebelum End User mengaktifkan fitur sensitif;
- saat login berbasis OTP;
- saat End User mengganti nomor HP.
Dalam konteks OTP.ID, **Client** adalah bisnis atau aplikasi yang mengintegrasikan API OTP.ID. **End User** adalah pengguna akhir milik Client yang menerima kode OTP. API key OTP.ID selalu milik Client, jadi jangan pernah menaruhnya di JavaScript browser atau aplikasi mobile.
Prasyarat sebelum mulai
Sebelum menulis kode, siapkan ini:
- Aplikasi Laravel yang sudah berjalan.
- API key OTP.ID dari dashboard Client.
- Kredit/saldo OTP.ID yang cukup untuk mengirim OTP.
- Base URL API: `https://api.otp.id`.
- Channel yang ingin dipakai. Tutorial ini memakai `whatsapp` sebagai contoh.
OTP.ID tidak menyediakan sandbox terpisah. Uji integrasi dengan nomor milik sendiri atau nomor test internal yang aman. Gunakan placeholder saat menulis dokumentasi atau demo publik, misalnya <END_USER_PHONE_NUMBER> atau 628xxxxxxxxxx.
Alur integrasi singkat
Alur minimalnya seperti ini:
- End User mengisi nomor HP di aplikasi Laravel.
- Backend Laravel memanggil `POST /v3/request` ke OTP.ID.
- OTP.ID mengembalikan `otp_id` dan status pengiriman.
- Laravel menyimpan `otp_id` sementara, misalnya di session, cache, atau tabel verifikasi.
- End User mengetik kode OTP.
- Backend Laravel memanggil `POST /v3/verify` dengan `otp_id` dan kode OTP.
- Jika `verified:true`, aplikasi melanjutkan flow register/login/verifikasi.

Dalam contoh ini, OTP digenerate oleh OTP.ID lewat mode request. Kalau kode OTP harus dibuat oleh sistem Client sendiri, OTP.ID juga punya mode send, tetapi tutorial ini sengaja memakai mode yang lebih umum untuk onboarding awal.
Konfigurasi environment Laravel
Simpan API key di .env, bukan di controller atau repository Git.
OTP_ID_BASE_URL=https://api.otp.id
OTP_ID_API_KEY=<OTP_ID_API_KEY>
OTP_ID_DEFAULT_CHANNEL=whatsapp
OTP_ID_DEFAULT_BRAND=TokoAnda
Tambahkan konfigurasi di config/services.php:
return [
// konfigurasi service lain...
'otpid' => [
'base_url' => env('OTP_ID_BASE_URL', 'https://api.otp.id'),
'api_key' => env('OTP_ID_API_KEY'),
'channel' => env('OTP_ID_DEFAULT_CHANNEL', 'whatsapp'),
'brand' => env('OTP_ID_DEFAULT_BRAND', 'TokoAnda'),
],
];
Pastikan .env tidak masuk Git. Kalau memakai deployment platform, simpan nilai OTP_ID_API_KEY di secret manager atau environment variable platform tersebut.
Membuat service client OTP.ID
Buat service kecil agar controller tidak berisi detail HTTP mentah.
Contoh file: app/Services/OtpIdClient.php
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;
class OtpIdClient
{
private string $baseUrl;
private string $apiKey;
public function __construct()
{
$this->baseUrl = rtrim((string) config('services.otpid.base_url'), '/');
$this->apiKey = (string) config('services.otpid.api_key');
}
private function http(): PendingRequest
{
return Http::baseUrl($this->baseUrl)
->acceptJson()
->asJson()
->withToken($this->apiKey)
->timeout(15);
}
public function requestOtp(string $destination, ?string $externalId = null): Response
{
return $this->http()->post('/v3/request', [
'channel' => config('services.otpid.channel', 'whatsapp'),
'destination' => $destination,
'brand' => config('services.otpid.brand', 'TokoAnda'),
'otp_length' => 6,
'ttl' => 300,
'external_id' => $externalId,
]);
}
public function verifyOtp(string $otpId, string $otp): Response
{
return $this->http()->post('/v3/verify', [
'otp_id' => $otpId,
'otp' => $otp,
]);
}
}
Catatan penting:
- `Authorization: Bearer <API key>` dibuat oleh `withToken()`.
- `destination` berisi nomor tujuan format internasional tanpa tanda `+`, misalnya `628xxxxxxxxxx`.
- `external_id` dipakai sebagai ID referensi dari sistem Client agar request lebih mudah dilacak dan direkonsiliasi. Jangan mengandalkan behavior idempotency sebelum divalidasi langsung ke backend OTP.ID.
- Timeout HTTP ditetapkan agar request aplikasi tidak menggantung terlalu lama.
Route dan controller minimal
Tambahkan route demo di routes/web.php atau routes/api.php. Untuk aplikasi nyata, sesuaikan middleware, throttling, dan penyimpanan datanya.
use App\Http\Controllers\OtpVerificationController;
use Illuminate\Support\Facades\Route;
Route::post('/otp/request', [OtpVerificationController::class, 'request']);
Route::post('/otp/verify', [OtpVerificationController::class, 'verify']);
Buat controller:
<?php
namespace App\Http\Controllers;
use App\Services\OtpIdClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class OtpVerificationController extends Controller
{
public function request(Request $request, OtpIdClient $otpId): JsonResponse
{
$validated = $request->validate([
'phone' => ['required', 'string', 'min:10', 'max:20'],
]);
$phone = preg_replace('/\D+/', '', $validated['phone']);
$externalId = 'otp-' . Str::uuid()->toString();
$response = $otpId->requestOtp($phone, $externalId);
$body = $response->json();
if (! $response->successful() || data_get($body, 'success') !== true) {
return response()->json([
'message' => 'Gagal meminta OTP.',
'error_code' => data_get($body, 'error.code'),
'error_message' => data_get($body, 'error.message'),
], $response->status());
}
if (data_get($body, 'data.status') === 'failed') {
return response()->json([
'message' => 'OTP belum berhasil dikirim. Coba lagi atau pilih channel lain.',
], 422);
}
session([
'otp_id' => data_get($body, 'data.otp_id'),
'otp_destination' => $phone,
]);
return response()->json([
'message' => 'OTP berhasil diminta.',
'otp_id' => data_get($body, 'data.otp_id'),
'status' => data_get($body, 'data.status'),
'expires_at' => data_get($body, 'data.expires_at'),
]);
}
public function verify(Request $request, OtpIdClient $otpId): JsonResponse
{
$validated = $request->validate([
'otp' => ['required', 'string', 'min:4', 'max:8'],
]);
$otpIdValue = session('otp_id');
if (! $otpIdValue) {
return response()->json([
'message' => 'Sesi OTP tidak ditemukan. Minta kode baru.',
], 422);
}
$response = $otpId->verifyOtp($otpIdValue, $validated['otp']);
$body = $response->json();
if ($response->successful() && data_get($body, 'data.verified') === true) {
session()->forget(['otp_id', 'otp_destination']);
return response()->json([
'message' => 'Nomor berhasil diverifikasi.',
]);
}
if ($response->successful() && data_get($body, 'data.verified') === false) {
return response()->json([
'message' => 'Kode OTP salah.',
'reason' => data_get($body, 'data.reason'),
], 422);
}
return response()->json([
'message' => 'Verifikasi OTP gagal.',
'error_code' => data_get($body, 'error.code'),
'error_message' => data_get($body, 'error.message'),
], $response->status());
}
}
Untuk production, jangan hanya mengandalkan session kalau flow berjalan lintas device atau butuh audit. Simpan otp_id, nomor tujuan, status, dan external_id di tabel khusus, lalu scope-kan ke akun atau intent verifikasi yang benar.
Handling response dan error yang wajib dipikirkan
Ada beberapa response yang perlu ditangani secara eksplisit:
| Kondisi | Sumber response | Tindakan di aplikasi Laravel |
|---|---|---|
| Request sukses | success:true, data.status bukan failed | Simpan otp_id, tampilkan input OTP. |
| Pengiriman gagal | HTTP 200 dengan data.status:"failed" | Beri pesan coba lagi; Client bisa memutuskan channel lain. |
| Kode salah | HTTP 200 dengan verified:false, reason:"mismatch" | Tampilkan pesan kode salah tanpa menganggap API error. |
| OTP expired | OTP_EXPIRED | Minta End User request kode baru. |
| Terlalu banyak percobaan | TOO_MANY_ATTEMPTS | Kunci flow sementara dan minta request baru. |
| OTP sudah dipakai | ALREADY_USED | Jangan terima kode yang sama lagi. |
| Kredit tidak cukup | INSUFFICIENT_BALANCE | Tampilkan error internal ke admin/support, bukan detail saldo ke End User. |
| Rate limit | RATE_LIMITED / DESTINATION_RATE_LIMITED | Terapkan cooldown UI dan jangan retry agresif. |

Jangan menampilkan error_message mentah ke End User tanpa kurasi. Pesan teknis berguna untuk developer, tetapi End User cukup butuh instruksi seperti “Kode salah”, “Kode kedaluwarsa”, atau “Tunggu sebentar sebelum meminta kode baru”.
README/demo guide singkat
Struktur demo minimal:
laravel-otp-id-demo/
├── app/Http/Controllers/OtpVerificationController.php
├── app/Services/OtpIdClient.php
├── config/services.php
├── routes/web.php
└── .env.example
Isi .env.example:
OTP_ID_BASE_URL=https://api.otp.id
OTP_ID_API_KEY=<OTP_ID_API_KEY>
OTP_ID_DEFAULT_CHANNEL=whatsapp
OTP_ID_DEFAULT_BRAND=TokoAnda
Cara menjalankan demo lokal:
composer install
cp .env.example .env
php artisan key:generate
php artisan serve
Contoh request lokal:
curl -X POST http://127.0.0.1:8000/otp/request \
-H "Content-Type: application/json" \
-d '{"phone":"<END_USER_PHONE_NUMBER>"}'
Lalu verifikasi:
curl -X POST http://127.0.0.1:8000/otp/verify \
-H "Content-Type: application/json" \
-d '{"otp":"123456"}'
Ganti 123456 dengan kode yang diterima di nomor test. Jangan menaruh kode OTP asli di log, screenshot, atau dokumentasi publik.
Checklist sebelum production
Sebelum fitur OTP dipakai End User sungguhan, cek ini:
- [ ] API key disimpan di environment variable atau secret manager.
- [ ] API OTP.ID hanya dipanggil dari backend Laravel.
- [ ] Nomor HP dinormalisasi dan divalidasi sebelum dikirim ke OTP.ID.
- [ ] `otp_id` disimpan dengan scope yang benar, misalnya per akun/register attempt.
- [ ] Ada cooldown tombol kirim ulang di UI.
- [ ] Semua response penting ditangani: `mismatch`, `OTP_EXPIRED`, `TOO_MANY_ATTEMPTS`, `ALREADY_USED`, `INSUFFICIENT_BALANCE`, `RATE_LIMITED`, `DESTINATION_RATE_LIMITED`.
- [ ] Log aplikasi tidak menyimpan API key, kode OTP, atau nomor HP mentah bila tidak diperlukan.
- [ ] Ada jalur support saat pengiriman OTP gagal.
- [ ] Flow sudah diuji dengan nomor benar, nomor format salah, kode salah, kode kedaluwarsa, dan percobaan berulang.
Penutup
Dengan pola ini, Laravel tetap memegang kontrol flow aplikasi, sementara OTP.ID menangani pengiriman dan verifikasi kode OTP lewat API. Bagian yang paling penting untuk production bukan hanya request berhasil, tapi juga cara aplikasi menyimpan otp_id, membatasi resend, dan memberi pesan yang jelas saat verifikasi gagal.
