fouladgar/laravel-mobile-verification
A Laravel package for verifying mobile numbers with one-time passwords (OTP). It generates and validates verification codes, integrates with SMS providers, and offers configurable token storage, expiration, and resend controls for secure phone verification flows.
1. Install and publish
composer require fouladgar/laravel-mobile-verification
php artisan vendor:publish --provider="Fouladgar\MobileVerification\MobileVerificationServiceProvider"
This publishes config/mobile_verification.php and the migration for the mobile_verifications table. Run php artisan migrate.
The first place to look is the published config file — it is short and controls everything you will tune day-to-day: code length, expiration window, table name, and the SMS client class.
2. Wire up an SMS client
The package does not ship a real SMS provider; it hands a message to whatever class you configure. Implement the contract:
namespace App\Services;
use Fouladgar\MobileVerification\Contracts\SmsClient;
class KavenegarClient implements SmsClient
{
public function sendMessage(string $recipient, string $message): void
{
// $recipient is whatever you passed to send(); $message already contains the code
Kavenegar::send($recipient, $message);
}
}
Then point the config at it:
// config/mobile_verification.php
'sms_client' => App\Services\KavenegarClient::class,
3. Add the trait (optional but recommended)
If you want verification tied to an Eloquent model (the common case), add the trait:
use Fouladgar\MobileVerification\Traits\HasMobileVerification;
class User extends Authenticatable
{
use HasMobileVerification;
}
4. First use case — registration/OTP login
The classic flow: user enters a phone number, you send a code, they submit the code, you verify it, then you create or log them in.
use Fouladgar\MobileVerification\Facades\MobileVerification;
// Step 1 — send
MobileVerification::send($request->input('mobile'));
// Step 2 — verify
$ok = MobileVerification::verify($request->input('mobile'), $request->input('code'));
if ($ok) {
// create session / issue token
}
That two-call surface (send + verify) is the entire package in one glance. Everything else is configuration and integration.
Stateless facade flow (registration, password reset, OTP login). Use MobileVerification::send() and MobileVerification::verify() when there is no authenticated user yet. This is the most common pattern — a SendCodeController / VerifyCodeController pair, or a single AuthenticateWithOtp action. Keep the mobile number out of the "is this user logged in" logic entirely; the facade does not care.
Model-bound flow (profile phone verification). When a logged-in user is confirming or changing their phone number, use the trait so the verified state is persisted on the row:
$user->sendMobileVerificationCode($request->input('mobile'));
// later
if ($user->verifyMobile($request->input('mobile'), $request->input('code'))) {
$user->markMobileAsVerified();
}
Check the exact method names on the trait in your installed version (vendor/fouladgar/laravel-mobile-verification/src/Traits/HasMobileVerification.php) — they are stable in spirit but worth confirming once.
Wrap it in an action/service. Do not scatter MobileVerification::send() calls across controllers. A dedicated SendVerificationCode action lets you add throttling, audit logging, and queued dispatch in one place:
class SendVerificationCode
{
public function __invoke(string $mobile): void
{
RateLimiter::hit('otp:'.$mobile, 60);
MobileVerification::send($mobile);
}
}
Pair it with your own throttle. The package handles code expiry and reuse, but rate limiting per phone number and per IP is your responsibility. Use Laravel's RateLimiter in a middleware or FormRequest and return 429 before the package is ever invoked. That keeps abuse protection independent of the package's internals.
Integrate with Fortify/Breeze/Jetstream by replacing the password step. For OTP login, override the authentication pipeline so that on success you call Auth::login($user) yourself. The package verifies a code; it does not create sessions or tokens. Keeping that boundary explicit avoids confusion later.
Queue the outbound SMS. Your SmsClient implementation can simply dispatch a job. The package calls the client synchronously, so if your provider is slow, making the client itself queue-backed is the least invasive fix:
public function sendMessage(string $recipient, string $message): void
{
SendSmsJob::dispatch($recipient, $message);
}
Testing. Because the package persists codes in the mobile_verifications table, tests can either assert the row exists or drive the full flow. For unit tests of your own code, bind a fake client:
$this->instance(SmsClient::class, new class implements SmsClient {
public array $sent = [];
public function sendMessage(string $r, string $m): void { $this->sent[] = [$r, $m]; }
});
Then assert $fake->sent contains the mobile. For end-to-end feature tests, pull the code from the DB row rather than mocking internals — it keeps tests honest about the real flow.
Config-driven environments. In local/CI, bind a LogSmsClient that writes the code to laravel.log, and never send real SMS. This is the single most useful dev-time pattern with this package.
The sms_client must be resolvable from the container. The package resolves it via app(), so a class with constructor dependencies works, but a hard-coded new inside your client will bite you. Register bindings in a service provider if you use interfaces.
Normalize phone numbers before calling send() and verify(). The package keys codes by the exact string you pass. +989121234567 and 09121234567 are two different recipients as far as the DB is concerned — a user who signs up with one format and verifies with another will always fail. Strip spaces and enforce E.164 in a FormRequest before the package sees the number.
Re-run php artisan config:cache after touching mobile_verification.php. In production, a stale config cache means a change to code length or expiration silently does nothing. This is the most common "it worked locally" bug.
Codes are single-use and expire; do not cache them in Redis and expect the package to respect it. The source of truth is the mobile_verifications table. If you purge that table (e.g., in a data-retention job), in-flight verifications break.
Clear the table on a schedule. The package inserts a row per send and does not aggressively prune. Add a model pruning task or a scheduled delete where created_at < now()->subDay() to keep the table small; large tables slow down verification lookups and make mobile columns worth indexing (they usually are, but confirm).
Watch expiration units. Expiration is expressed in minutes in the config. If you want 2 minutes, use 2, not 120 or 120 seconds — check the published file's comment rather than guessing, because a factor-of-60 mistake is easy and only shows up as "codes never work" in QA.
Debug by querying the row, not the logs. When verification fails, look at mobile_verifications for that mobile: check created_at for expiry and confirm the stored code matches what the user received. Most "wrong code" reports are actually format mismatches or an expired row.
Do not call send() inside a DB transaction that might roll back. The row insert and the SMS send are not atomic — rolling back the transaction can delete the code while the SMS is already delivered. Send outside the transaction, after commit.
Guard against double-submits on the verify endpoint. Users tapping "Verify" twice is normal; make the endpoint idempotent (check hasVerifiedMobile() first) so the second request returns success rather than "code already used."
Extension points are thin. There is no rich event surface or pluggable code generator in the typical version — if you need audit trails, custom code shapes (e.g., alphanumeric), or per-tenant clients, wrap the facade in your own service and keep the package as a low-level primitive. That wrapper is also the right seam for swapping the package later without touching controllers.
Prefer the facade over the trait when you may verify numbers that don't belong to a user yet. Reaching for the trait too early forces you into "create user, then verify," which produces orphaned users when verification is abandoned. Verify first, persist after.
How can I help you explore Laravel packages today?