syriable / laravel-otp-verification
A backend-only, provider-agnostic phone verification (OTP) package for Laravel
Package info
github.com/syriable/syriable-laravel-otp-verification
pkg:composer/syriable/laravel-otp-verification
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/auth: ^12.0||^13.0
- illuminate/bus: ^12.0||^13.0
- illuminate/cache: ^12.0||^13.0
- illuminate/console: ^12.0||^13.0
- illuminate/contracts: ^12.0||^13.0
- illuminate/database: ^12.0||^13.0
- illuminate/queue: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- nesbot/carbon: ^3.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.5
- rector/rector-laravel: ^2.5
- spatie/laravel-ray: ^1.35
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-24 02:54:01 UTC
README
A backend-only, provider-agnostic phone verification (OTP) system for Laravel. Send one-time passwords through any SMS provider, verify them securely, and get rich result objects instead of exceptions — with rate limiting, resend cooldowns, attempt limits, and brute-force protection built in.
use Syriable\PhoneVerification\Facades\PhoneVerification; $result = PhoneVerification::send('+31612345678'); $result = PhoneVerification::verify(phone: '+31612345678', code: '482913'); if ($result->successful()) { // the phone number is verified }
The package ships no UI, no views, and no frontend assets — just a clean service layer and a facade you can wire into any API, mobile backend, or SPA.
Why this package?
- Provider agnostic — bring your own sender: Twilio, Vonage, AWS SNS, MessageBird, Sinch, or a plain HTTP call. The package never dictates a provider.
- Secure by default — codes are stored as HMAC-SHA256 hashes (never plain text), compared in constant time, invalidated on success, expiry, and attempt exhaustion, and shielded by two layers of rate limiting.
- Rich results, no exceptions — expected outcomes (
expired,invalid,tooManyAttempts, …) are values you branch on, not exceptions you catch. - Everything is replaceable — generator, sender, repository, rate limiter, and hasher all sit behind small interfaces bound through the config file.
Installation
You can install the package via composer:
composer require syriable/laravel-phone-verification
Publish and run the migrations:
php artisan vendor:publish --tag="laravel-phone-verification-migrations"
php artisan migrate
Optionally publish the config file:
php artisan vendor:publish --tag="laravel-phone-verification-config"
Finally, tell the package how to deliver codes by pointing the sender config option at your own sender class (see Sending codes):
// config/phone-verification.php 'sender' => App\Verification\TwilioSender::class,
Configuration
Every behavior is configurable. This is the published config file:
return [ // Toggle the whole feature. When disabled, send() returns a failed result. 'enabled' => env('PHONE_VERIFICATION_ENABLED', true), // Optional ISO country code, available to your sender for E.164 formatting. 'default_country' => env('PHONE_VERIFICATION_DEFAULT_COUNTRY'), // Minutes a code stays valid. 'expiration' => 5, // Seconds a user must wait before requesting another code. 'resend_after' => 60, // How many times a single code may be checked before it becomes unusable. 'max_attempts' => 5, // At most `max_send_attempts` codes per phone per `per_minutes` minutes. 'max_send_attempts' => 3, 'per_minutes' => 15, // Code shape: length, type (numeric|alphabetic|alphanumeric), // a custom character set, or a fully custom generator class. 'otp' => [ 'length' => 6, 'type' => 'numeric', 'characters' => null, 'generator' => null, ], // Your PhoneVerificationSender implementation. Required. 'sender' => null, // Storage, throttling and hashing — all swappable. 'repository' => DatabaseVerificationRepository::class, 'table' => 'phone_verifications', 'rate_limiter' => CacheSendRateLimiter::class, 'hash_driver' => HmacCodeHasher::class, // How long `verification:cleanup` keeps verified records around. 'cleanup' => [ 'keep_verified_for_days' => 7, ], ];
Sending codes
First, write a sender. It receives the phone number and the plain-text code — how it reaches the phone is entirely up to you:
namespace App\Verification; use Syriable\PhoneVerification\Contracts\PhoneVerificationSender; use Twilio\Rest\Client; class TwilioSender implements PhoneVerificationSender { public function __construct( private readonly Client $twilio, ) {} public function send(string $phone, string $code): void { $this->twilio->messages->create($phone, [ 'from' => config('services.twilio.from'), 'body' => "Your verification code is {$code}", ]); } }
Senders are resolved from the container, so constructor dependencies are injected automatically. Register it in the config:
'sender' => App\Verification\TwilioSender::class,
Example: Vonage
Install Vonage's official Laravel integration — it registers Vonage\Client in the container for you, so your sender needs no extra bindings:
composer require vonage/vonage-laravel
php artisan vendor:publish --provider="Vonage\Laravel\VonageServiceProvider"
Set your credentials in .env:
VONAGE_KEY=your_api_key
VONAGE_SECRET=your_secret
VONAGE_FROM=Verify
namespace App\Verification; use Syriable\PhoneVerification\Contracts\PhoneVerificationSender; use Vonage\Client; use Vonage\SMS\Message\SMS; class VonageSender implements PhoneVerificationSender { public function __construct( private readonly Client $vonage, ) {} public function send(string $phone, string $code): void { $this->vonage->sms()->send( new SMS($phone, env('VONAGE_FROM'), "Your verification code is {$code}") ); } }
Then point the package at it:
'sender' => App\Verification\VonageSender::class,
Then send a code:
use Syriable\PhoneVerification\Facades\PhoneVerification; $result = PhoneVerification::send('+31612345678'); $result->successful(); // true when the code was generated and handed to your sender $result->onCooldown(); // a code was sent too recently $result->rateLimited(); // too many codes in the configured window $result->disabled(); // the package is disabled via config $result->retryAfter(); // seconds until sending may succeed again (cooldown/rate limit) $result->verification; // the stored VerificationRecord (hash only, never the code)
Sending a new code automatically invalidates any previous unverified code for that phone number — only one code is ever active.
A typical controller:
public function store(Request $request) { $result = PhoneVerification::send($request->string('phone')->value()); if ($result->failed()) { return response()->json([ 'message' => 'Please wait before requesting another code.', 'retry_after' => $result->retryAfter(), ], 429); } return response()->json(['message' => 'Code sent.']); }
Verification
$result = PhoneVerification::verify( phone: '+31612345678', code: '482913', );
Expected outcomes are values on a rich result object — no exceptions to catch:
$result->successful(); // code correct, phone verified $result->invalid(); // wrong code, attempts remain $result->expired(); // the code expired $result->tooManyAttempts(); // the attempt limit made the code unusable $result->alreadyVerified(); // the phone was verified earlier (replay protection) $result->notFound(); // no code was ever sent to this phone $result->failed(); // shorthand for "not successful" $result->outcome; // the VerificationOutcome enum behind the booleans $result->attemptsRemaining; // attempts left on the active code, when relevant
Once a code has been used successfully it can never be replayed: a second verify() call with the same (or any) code returns alreadyVerified().
To link the phone number to one of your own models (e.g. a User) the moment verification succeeds, pass it as for — see Linking a phone to a model:
$result = PhoneVerification::verify(phone: '+31612345678', code: '482913', for: $user); $result->phoneTakenByAnotherAccount(); // the code was correct, but the phone belongs to a different account
Checking status
$status = PhoneVerification::status('+31612345678'); $status->isVerified(); // the phone completed verification $status->isPending(); // a code is out and still valid $status->isExpired(); // the active code expired $status->isNone(); // nothing on record $status->expiresAt; // when the pending code expires $status->verifiedAt; // when verification succeeded $status->attemptsRemaining; // attempts left on the pending code // or, as a one-liner: PhoneVerification::isVerified('+31612345678');
Resending
resend() invalidates the previous code, sends a fresh one, and tracks how often the user asked for another:
$result = PhoneVerification::resend('+31612345678'); $result->verification?->resendCount; // 1, 2, 3, ...
The resend cooldown (resend_after) applies to both send() and resend(). Before it elapses you get a failed result with retryAfter() filled in — perfect for a countdown in your frontend.
Invalidating
Cancel any outstanding code, for example after the user changes their phone number:
PhoneVerification::invalidate('+31612345678');
Linking a phone to a model
Each phone number can be linked to exactly one of your Eloquent models — typically a User, but any model works via a polymorphic (morphTo) relationship. This lives in its own phone_verification_links table, entirely separate from the OTP lifecycle, so linking is optional and never changes how send()/resend() behave.
Add the trait to any model that can own a verified phone number:
use Illuminate\Foundation\Auth\User as Authenticatable; use Syriable\PhoneVerification\Concerns\HasVerifiedPhone; class User extends Authenticatable { use HasVerifiedPhone; }
This gives you a real Eloquent relation plus two convenience helpers:
$user->phoneVerificationLink; // MorphOne<PhoneVerificationLink> — eager-loadable, e.g. User::with('phoneVerificationLink') $user->verifiedPhoneNumber(); // '+31612345678' or null $user->hasVerifiedPhoneNumber(); // bool
The most common flow is auto-linking on successful verification, by passing the model as for:
$result = PhoneVerification::verify(phone: '+31612345678', code: '482913', for: $user); $result->successful(); // linked: the code was correct and the phone wasn't taken $result->phoneTakenByAnotherAccount(); // the code was correct, but the phone already belongs to someone else
A phone number can only ever be linked to one model at a time — verifying it a second time for the same model is idempotent, but verifying it for a different model fails with phoneTakenByAnotherAccount() instead of silently stealing the number.
You can also manage links directly, independently of the OTP flow (for example when you already know a phone was verified through another channel, or want to detach it):
PhoneVerification::link('+31612345678', $user); // true, or false if taken by another model PhoneVerification::linkedTo('+31612345678'); // the linked model, or null PhoneVerification::phoneFor($user); // the phone linked to this model, or null PhoneVerification::unlink('+31612345678'); // removes the link, returns 0 or 1
Storage goes through the PhoneLinkRepository contract, so it's swappable like everything else — set link_repository in the config to your own implementation.
The published create_phone_verification_links_table migration uses $table->morphs('verifiable'), which assumes auto-incrementing integer model keys. If your models use UUIDs, edit the published migration to use $table->uuidMorphs('verifiable') (or $table->ulidMorphs('verifiable')) instead.
Custom OTP generation
Tune the generated codes through config:
'otp' => [ 'length' => 8, 'type' => 'alphanumeric', // numeric, alphabetic, alphanumeric ],
The alphabetic and alphanumeric sets deliberately exclude ambiguous characters (0/O, 1/I). Prefer full control? Provide your own characters:
'otp' => [ 'length' => 6, 'characters' => 'ACDEFGHJKLMNPQRTUVWXY34679', ],
Or replace the generator entirely with any class implementing OtpGenerator:
namespace App\Verification; use Syriable\PhoneVerification\Contracts\OtpGenerator; class WordOtpGenerator implements OtpGenerator { public function generate(): string { return collect(['apple', 'river', 'sunny'])->random() .random_int(100, 999); } }
'otp' => [ 'generator' => App\Verification\WordOtpGenerator::class, ],
Use a cryptographically secure random source (random_int(), random_bytes()) in custom generators — predictable codes defeat the purpose.
Repository customization
All storage goes through the VerificationRepository interface. The default DatabaseVerificationRepository persists to the phone_verifications table, but you can point the package at Redis, the cache, or an external service without touching any package logic:
'repository' => App\Verification\RedisVerificationRepository::class,
Your implementation needs to cover creating records, finding the active/verified record for a phone, tracking the last send time, incrementing attempts, marking success, invalidating, pruning, and clearing — see the interface for the exact signatures. Records travel through the package as immutable VerificationRecord value objects, so repositories stay completely decoupled from Eloquent.
The same pattern applies to the other extension points:
| Config key | Interface | Default |
|---|---|---|
otp.generator |
OtpGenerator |
RandomOtpGenerator |
sender |
PhoneVerificationSender |
— (required) |
repository |
VerificationRepository |
DatabaseVerificationRepository |
rate_limiter |
SendRateLimiter |
CacheSendRateLimiter |
hash_driver |
CodeHasher |
HmacCodeHasher |
link_repository |
PhoneLinkRepository |
DatabasePhoneLinkRepository |
Every configured class is validated against its interface at resolve time; a misconfigured class throws a descriptive InvalidConfiguration exception.
Events
The package dispatches plain event objects you can listen to:
| Event | Dispatched when |
|---|---|
VerificationCreated |
a new code was generated and stored |
VerificationSent |
the sender delivered a code |
VerificationResent |
the code was sent through resend() (follows VerificationSent) |
VerificationSucceeded |
a code was verified successfully |
VerificationFailed |
a wrong code was submitted (outcome tells you whether attempts ran out) |
VerificationExpired |
a verification attempt hit an expired code |
PhoneLinked |
a phone number was linked to a model, via verify(..., for: $model) or link() |
Every event exposes the immutable VerificationRecord — never the plain-text code:
use Syriable\PhoneVerification\Events\VerificationSucceeded; class ActivateCustomer { public function handle(VerificationSucceeded $event): void { Customer::wherePhone($event->verification->phone)->firstOrFail()->activate(); } }
Console commands & scheduling
# Remove expired codes and verified records older than the configured retention php artisan verification:cleanup # Remove all verification records, or only those of one phone number php artisan verification:clear php artisan verification:clear +31612345678
Schedule the cleanup to keep the table lean:
// routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('verification:cleanup')->daily();
Testing your integration
The package ships a FakeSender that captures codes in memory instead of delivering them:
use Syriable\PhoneVerification\Facades\PhoneVerification; use Syriable\PhoneVerification\Testing\FakeSender; beforeEach(function () { $this->app->singleton(FakeSender::class); config()->set('phone-verification.sender', FakeSender::class); }); it('verifies a phone during registration', function () { $this->postJson('/verification', ['phone' => '+31612345678'])->assertOk(); $sender = app(FakeSender::class); $sender->assertSentTo('+31612345678'); $code = $sender->lastCodeFor('+31612345678'); $this->postJson('/verification/confirm', [ 'phone' => '+31612345678', 'code' => $code, ])->assertOk(); expect(PhoneVerification::isVerified('+31612345678'))->toBeTrue(); });
FakeSender also offers codesFor($phone), sentCount(), assertSentTo($phone, times: 2), assertNothingSent(), and reset().
Security
- No plain-text storage. Codes are stored as HMAC-SHA256 hashes keyed with your application key. The hash is bound to the phone number, so a leaked hash is useless for any other number — and it is hidden from model serialization.
- Constant-time comparison. Verification uses
hash_equals(); timing attacks reveal nothing. - Replay protection. A code becomes unusable the moment it succeeds; repeat submissions return
alreadyVerified(). - Automatic invalidation. Codes die on success, on expiry, when the attempt limit is reached, and whenever a new code is issued.
- Brute-force protection. Verification attempts per code are capped (
max_attempts), and sends are throttled twice: a per-send cooldown (resend_after) and a rolling window (max_send_attemptsperper_minutes). Rate-limiter cache keys hash the phone number. - No sensitive logging. The package never logs codes or phone numbers; the plain-text code only ever touches your sender.
Found a vulnerability? Please review our security policy.
Best practices
- Normalize phone numbers to E.164 (
+31612345678) before calling the package — it treats numbers as opaque strings, so+31 6 12345678and+31612345678would be two different identities. - Keep expiration short (5–10 minutes) and codes at 6+ characters.
- Add per-IP/user throttling on your HTTP endpoints on top of the built-in per-phone limits (
ThrottleRequestsmiddleware works well). - Surface
retryAfter()in your API responses so clients can display an accurate countdown. - Schedule
verification:cleanupdaily; expired rows are useless and verified rows only need to live as long as your product needs the audit trail. - Never echo the code back in any API response, error message, or log — deliver it exclusively through the sender.
Upgrade guide
This is the initial release, so there is nothing to upgrade from yet. Future releases will follow semver:
- Patch/minor releases never require changes.
- Major releases will document every breaking change here, including config keys to rename, interface methods to add, and a migration path for stored data.
After upgrading, re-publish the config when the changelog says new options were added:
php artisan vendor:publish --tag="laravel-phone-verification-config" --force
Testing the package
composer test
composer analyse
composer format
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Credits
License
The MIT License (MIT). Please see License File for more information.