laravel-otp-verification maintained by syriable
OTP verification for Laravel
Verify that someone controls an identifier — a phone number, an email address, a chat handle — by sending them a one-time code, over any channel, through any provider.
use Syriable\OtpVerification\Channel;
use Syriable\OtpVerification\Facades\Verification;
// SMS // Email
Verification::send('+31612345678', Verification::send('ada@example.com',
Channel::sms()); Channel::mail());
Verification::verify('+31612345678', Verification::verify('ada@example.com',
'482913', Channel::sms()); 'K7M2P9QX', Channel::mail());
Both channels are first-class. They have their own sender, their own code shape, their own expiry, and their own throttling — and the same address can hold an independent code on each at the same time.
The package ships no UI, no views, no routes, and no frontend assets — just a service layer and a facade you wire into your own API, mobile backend, or SPA.
Why this package?
- Any channel, any provider. SMS and email out of the box; WhatsApp, Telegram or push by adding a config block and a class — no release of this package required.
- Secure by default. Codes are stored as HMAC-SHA256 hashes bound to
(identifier, channel), compared in constant time, and invalidated on success, expiry and attempt exhaustion, behind two independent layers of throttling. - Rich results, no exceptions. Expected outcomes (
expired,invalid,tooManyAttempts, …) are values you branch on, not exceptions you catch. - Everything is replaceable. Generator, sender, repository, link storage, rate limiter and hasher all sit behind small contracts resolved from config.
Installation
composer require syriable/laravel-otp-verification
Publish and run the migrations:
php artisan vendor:publish --tag="otp-verification-migrations"
php artisan migrate
Publish the config file:
php artisan vendor:publish --tag="otp-verification-config"
Then point each channel you use at a sender class of your own (see Senders):
// config/otp-verification.php
'channels' => [
'sms' => ['sender' => App\Verification\TwilioSender::class],
'mail' => ['sender' => App\Verification\MailOtpSender::class],
],
Identifiers are opaque
This package never parses what you give it. It does not know what a valid phone number is, and it does not know what a valid email address is. It stores the string, hashes it, and compares it byte for byte.
That means normalisation is your job, before you call it:
| Channel | Normalise to | Because |
|---|---|---|
sms |
E.164, e.g. +31612345678 |
0612345678 and +31612345678 are two different identities here. |
mail |
A lowercased address, e.g. ada@example.com |
Ada@Example.com and ada@example.com are two different identities here. |
Validate format with Laravel's own rules (email, or a package like propaganistas/laravel-phone) before calling send().
Choosing a channel
Three equivalent ways, in increasing order of terseness:
// 1. Explicit — always unambiguous.
Verification::send('ada@example.com', Channel::mail());
// 2. Fluent, when a block of code works on one channel throughout.
$mail = Verification::channel(Channel::mail());
$mail->send('ada@example.com');
$mail->verify('ada@example.com', $code, for: $user);
// 3. Implicit, using `default_channel` from the config file.
Verification::send('+31612345678');
If you use more than one channel, set
'default_channel' => null. The channel argument then becomes required, and passing an email address to a call that would otherwise have defaulted to SMS fails loudly instead of quietly sending the wrong thing.
Senders
A sender receives an OtpMessage and delivers it. How it reaches the person is entirely up to you.
An SMS sender
namespace App\Verification;
use Syriable\OtpVerification\Contracts\OtpSender;
use Syriable\OtpVerification\Support\OtpMessage;
use Twilio\Rest\Client;
final readonly class TwilioSender implements OtpSender
{
public function __construct(private Client $twilio) {}
public function send(OtpMessage $message): void
{
$this->twilio->messages->create($message->identifier(), [
'from' => config('services.twilio.from'),
'body' => "Your code is {$message->code}. It expires in {$message->expiresInMinutes()} minutes.",
]);
}
}
An email sender
namespace App\Verification;
use Illuminate\Contracts\Mail\Mailer;
use Syriable\OtpVerification\Contracts\OtpSender;
use Syriable\OtpVerification\Support\OtpMessage;
final readonly class MailOtpSender implements OtpSender
{
public function __construct(private Mailer $mailer) {}
public function send(OtpMessage $message): void
{
$this->mailer
->to($message->identifier())
->send(new \App\Mail\VerificationCode($message->code, $message->expiresInMinutes()));
}
}
Senders are resolved from the container, so constructor injection works. The message carries its channel ($message->channel()), so one class can serve several channels if you register it under each.
Never log, persist, or attach the code to an error report.
OtpMessageis the only place the plain-text code exists, and handing it to your sender is the only moment it leaves the package.
Verifying
$result = Verification::verify('ada@example.com', $code, Channel::mail());
match (true) {
$result->successful() => redirect()->route('dashboard'),
$result->invalid() => back()->withErrors(['code' => "That code isn't right. {$result->attemptsRemaining} attempts left."]),
$result->expired() => back()->withErrors(['code' => 'That code has expired. Request a new one.']),
$result->tooManyAttempts() => back()->withErrors(['code' => 'Too many attempts. Request a new code.']),
$result->notFound() => back()->withErrors(['code' => 'Request a code first.']),
$result->alreadyVerified() => redirect()->route('dashboard'),
default => back()->withErrors(['code' => 'Could not verify that code.']),
};
Sending returns a result too:
$result = Verification::send('+31612345678', Channel::sms());
if ($result->onCooldown() || $result->rateLimited()) {
return response()->json(['retry_after' => $result->retryAfter()], 429);
}
Status and invalidation
Verification::status('+31612345678', Channel::sms()); // pending | verified | expired | none
Verification::isVerified('ada@example.com', Channel::mail());
Verification::invalidate('+31612345678', Channel::sms()); // kill outstanding codes
Verification::resend('+31612345678', Channel::sms());
Linking identifiers to your models
Add the trait to any model that can own verified identifiers:
use Syriable\OtpVerification\Concerns\HasVerifiedIdentifiers;
class User extends Authenticatable
{
use HasVerifiedIdentifiers;
}
Pass the model to verify() and it is linked the moment the code is confirmed:
$result = Verification::verify('+31612345678', $code, Channel::sms(), for: $user);
if ($result->identifierTakenByAnotherAccount()) {
return back()->withErrors(['phone' => 'That number already belongs to another account.']);
}
A model holds at most one verified identifier per channel, so the same user can carry a verified phone number and a verified email address at once:
$user->verifiedIdentifier(Channel::sms()); // '+31612345678'
$user->verifiedIdentifier(Channel::mail()); // 'ada@example.com'
$user->hasVerifiedIdentifier(Channel::mail());
$user->verifiedEmailAddress(); // sugar for the line above
Verification::linkedTo('+31612345678', Channel::sms()); // the User
Verification::identifierFor($user, Channel::sms()); // '+31612345678'
Verification::link('+31612345678', $user, Channel::sms());
Verification::unlink('+31612345678', Channel::sms());
Both directions are enforced by unique indexes: one identifier belongs to one model per channel, and one model holds one identifier per channel. Replacing a number is therefore an explicit unlink() then link(), never a silent overwrite.
Eager-load the relation when you touch a collection, and the accessors read from memory instead of querying per model:
User::query()->with('verificationLinks')->get();
Working with Laravel's email verification
This package can drive Laravel's own MustVerifyEmail / verified middleware, so you can replace signed verification links with OTP codes and keep everything downstream working.
Turn the bridge on:
// config/otp-verification.php
'mail' => ['mark_email_as_verified' => true],
Then verify with the user attached:
Verification::verify($user->email, $code, Channel::mail(), for: $user);
On success the package calls markEmailAsVerified() and dispatches Illuminate\Auth\Events\Verified, so the verified middleware and any listeners behave exactly as they would after a signed link.
The user must reach the bridge either as for: or through an existing link. The package deliberately never looks a user up by email address — that would let anyone who verifies an address mark an account they do not own as verified. It is inert while the config flag is off: the listener is never even registered.
Sending the code on registration
Hook Laravel's Registered event and send a code instead of the default notification:
namespace App\Listeners;
use Illuminate\Auth\Events\Registered;
use Syriable\OtpVerification\Channel;
use Syriable\OtpVerification\Facades\Verification;
final readonly class SendEmailVerificationCode
{
public function handle(Registered $event): void
{
Verification::send($event->user->getEmailForVerification(), Channel::mail());
}
}
Laravel's own SendEmailVerificationNotification listener is registered by the framework for Registered, so without one more step your users receive both a signed link and a code. Suppress the link by overriding the notification on your user model:
class User extends Authenticatable implements MustVerifyEmail
{
// Laravel calls this from its own listener; making it a no-op leaves the
// OTP as the only thing the user receives.
public function sendEmailVerificationNotification(): void
{
//
}
}
Then register your listener in AppServiceProvider::boot():
Event::listen(Registered::class, SendEmailVerificationCode::class);
Configuration
Every setting has a global default, and every one of them can be overridden per channel. Resolution is always:
channels.{channel}.{key} → {key} → the package default
so a channel overrides what it cares about and inherits the rest.
'expiration' => 5, // minutes a code stays valid
'resend_after' => 60, // seconds before another code may be requested
'max_attempts' => 5, // checks allowed against one code
'max_send_attempts' => 3, // codes per rolling window
'per_minutes' => 15, // the rolling window
'channels' => [
'sms' => [
'sender' => App\Verification\TwilioSender::class,
],
'mail' => [
'sender' => App\Verification\MailOtpSender::class,
'expiration' => 30, // email is read later, on another device
'resend_after' => 120,
'max_send_attempts' => 5, // email is free; SMS is not
'otp' => ['length' => 8, 'type' => 'alphanumeric'],
'cleanup' => ['keep_verified_for_days' => 30],
],
],
The defaults ship this way on purpose: SMS costs money per message, so its window is tighter; email is read minutes later on another device, so its codes live longer and are longer.
Adding your own channel
No package release needed:
'channels' => [
'whatsapp' => ['sender' => App\Verification\WhatsAppSender::class],
],
Verification::send('+31612345678', Channel::of('whatsapp'));
Queued delivery
Off by default — sends happen inside the request, so failures surface immediately in SendResult.
'queue' => true,
// or, per channel:
'channels' => ['sms' => ['queue' => ['connection' => 'redis', 'queue' => 'otp', 'tries' => 1]]],
Two things to know before turning it on:
- The plain-text code is written to your queue backend. The job implements
ShouldBeEncrypted, so it is encrypted at rest with your application key, but the code does leave the database boundary. - Delivery failures stop surfacing in
SendResult. A successful result then means "accepted for delivery", not "delivered".
tries defaults to 1 deliberately: a retried job sends a second real SMS, at real cost, to someone who only asked once.
Events
Every event carries the immutable record — including its channel — and never the plain-text code.
| Event | Dispatched when |
|---|---|
VerificationCreated |
a code has been generated and stored |
VerificationSent |
the sender has accepted the code |
VerificationResent |
the send was a resend |
VerificationSucceeded |
a code was verified (carries the model, if one was passed) |
VerificationFailed |
a code was rejected (carries the outcome) |
VerificationExpired |
an expired code was presented |
IdentifierLinked |
an identifier was linked to a model |
Console commands
php artisan verification:cleanup # prune every channel at its own retention
php artisan verification:cleanup --channel=sms
php artisan verification:clear # delete everything
php artisan verification:clear --channel=mail
php artisan verification:clear ada@example.com --channel=mail
Schedule the cleanup in routes/console.php:
Schedule::command('verification:cleanup')->daily();
Testing your integration
Point every channel at the shipped FakeSender and assert against what it captured:
use Syriable\OtpVerification\Channel;
use Syriable\OtpVerification\Testing\FakeSender;
$this->app->singleton(FakeSender::class);
config()->set('otp-verification.channels.sms.sender', FakeSender::class);
config()->set('otp-verification.channels.mail.sender', FakeSender::class);
$sender = $this->app->make(FakeSender::class);
Verification::send('ada@example.com', Channel::mail());
$sender->assertSentTo('ada@example.com', Channel::mail());
$sender->assertSentOn(Channel::mail(), times: 1);
$sender->assertNothingSentOn(Channel::sms());
$code = $sender->lastCodeFor('ada@example.com', Channel::mail());
Extending
Every collaborator is a contract resolved from config:
| Config key | Contract | Swap it when |
|---|---|---|
channels.*.sender |
OtpSender |
always — this is the one class you write |
channels.*.otp.generator, otp.generator |
OtpGenerator |
you need a check digit, a wordlist, or a different shape on one channel |
hash_driver |
CodeHasher |
you must hash in an HSM or with a separate pepper |
repository |
VerificationRepository |
codes belong in Redis, or need tenancy scoping |
link_repository |
LinkRepository |
identity links already live in your own schema |
rate_limiter |
SendRateLimiter |
you throttle on IP + identifier, or a shared provider quota |
models.* |
— | you want to extend the Eloquent models |
The public API takes strings and channels; the contracts take a VerificationSubject, which is an (identifier, channel) pair.
Security
- Codes are stored only as HMAC-SHA256 hashes keyed with your
APP_KEY, over a length-prefixed encoding of(channel, identifier, code)— so a hash can never be replayed against another identifier or another channel, and no identifier can be crafted to collide with another. - Comparison is constant time (
hash_equals). - Codes are invalidated on success, expiry, attempt exhaustion, and whenever a new code is issued.
- Two independent throttles: a per-identifier resend cooldown and a rolling send window.
- Rate-limiter cache keys hash the identifier, so no phone number or email address is written to your cache.
- Nothing in this package writes a code or an identifier to a log — enforced by an architecture test.
Rotating APP_KEY invalidates every outstanding code, by design.
Testing the package
composer test
composer analyse
Changelog
See CHANGELOG.md. Upgrading from v1? See UPGRADING.md.
Credits
License
The MIT License (MIT). See LICENSE.md.