Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Otp Validate Laravel Package

ferdous/laravel-otp-validate

A Laravel package for generating and validating one-time passwords (OTP) with ease. Send codes via email or SMS, customize OTP length and expiry, apply rate limiting, use multiple drivers, and drop secure OTP verification into any Laravel app with a simple API.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up (≈10 minutes):

composer require ferdous/laravel-otp-validate:^2.0
php artisan vendor:publish --provider="Ferdous\OtpValidator\OtpValidatorServiceProvider"
php artisan migrate

Pin the major (^2.0) rather than floating — the 2.x line is a major bump from the 1.x releases the README was written against, and 2.0.2 is a patch on top of changes we don't have notes for.

The publish step drops a config file into config/ (something like otpvalidator.php) and a migration that creates an otps table. That migration is where the package's retry/resend blocking actually lives — every count and expiry is a column, not a cache key — so read it before you read anything else.

Read these three things first, in this order:

  1. The migration — it tells you the real schema: identifier, otp value, attempt counter, resend counter, expiry, status. Everything the package promises is enforced against these columns.
  2. config/otpvalidator.php — the knobs you'll actually touch: OTP length, expiry window, max_retry, max_resend, channel selection (SMS vs email), and the template.
  3. src/Otp.php (the facade target) — the public surface is roughly send(), verify(), resend(). Confirm the exact argument order and return types here; the README is thin and lags the 2.x code, so the source is the source of truth.

Check the CHANGELOG for 2.0.0/2.0.1 before you upgrade an existing install. The 2.0.2 notes cover only a Guzzle fix, but the jump from the 2022-era 1.x line to 2.x is where any signature or config-key changes will live. Diff src/Otp.php and config/otpvalidator.php against what you have deployed.

First use case — verify a contact detail at signup:

// POST /otp/send
$result = Otp::send($request->email, 'email');   // shape varies — check src/Otp.php
session(['otp_unique_id' => $result->unique_id]);

// POST /otp/verify
$ok = Otp::verify($request->code, session('otp_unique_id'));

If that returns truthy, mark the email verified; if it throws or returns false, surface a generic error. Don't try to be clever on day one — get the happy path green, then harden.

Reality check before you commit: still a small package (69 stars, zero dependents), but it is maintained — 2.0.2 landed specifically to keep it working with current Guzzle, which is a good sign the maintainer is tracking the ecosystem. That said, treat it as a small piece of vendored logic you happen to install via Composer. Wrap it (see below) so ripping it out later is a one-file change.

Implementation Patterns

1. Always wrap the package in your own service.

This is the single highest-leverage decision. Your controllers should never import Ferdous\OtpValidator\*.

final class OtpService
{
    public function issue(string $identifier, string $channel = 'email'): string
    {
        return Otp::send($identifier, $channel)->unique_id;
    }

    public function check(string $uniqueId, string $code): bool
    {
        return (bool) Otp::verify($code, $uniqueId);
    }

    public function reissue(string $uniqueId): void
    {
        Otp::resend($uniqueId);
    }
}

Now a future swap to spatie/laravel-one-time-passwords or a hand-rolled implementation touches one class, and your tests can bind a fake. It also gives you a single place to translate the 2.x API shape if it drifted from 1.x.

2. Carry the unique_id in the session, not the URL.

Putting the identifier handle in a hidden form field invites tampering — an attacker can swap in someone else's handle and brute-force their OTP. Session storage (or an encrypted, signed hidden field) keeps the binding intact. The package's retry counter is keyed on that handle, so if the handle is attacker-controlled, so is the rate limit.

3. Two distinct flows — pick the right one.

  • Contact verification (email/phone confirm at signup): send after the user record exists, verify, flip a email_verified_at column. Low stakes, standard.
  • Passwordless login: the OTP is the credential. Here the package's resend blocking matters most, but you must add your own layer: per-IP throttling and a per-account lockout, because the package's counters are per-identifier and won't stop an attacker enumerating 10,000 emails one attempt each.

4. Layer Laravel's throttle on top of the built-in limits.

Route::post('/otp/send', [OtpController::class, 'send'])
    ->middleware('throttle:3,10');   // 3 sends per 10 minutes per IP

The package protects a single identifier; the middleware protects your infrastructure and your SMS bill.

5. Resend endpoint as a separate route with its own UX.

The package tracks resend counts, but your frontend still needs a countdown. Expose the remaining cooldown from your wrapper (read it off the OTP record or simply mirror the config value in your response) so the client disables the button instead of hammering the endpoint and burning the resend allowance.

6. Custom templates — set them once, centrally.

The template is config-driven. Keep the copy in config/ or a translation file rather than passing strings from controllers, so a marketing change is a config diff and every channel stays consistent. For SMS, confirm in the config whether your version wants a driver class bound or an event listener that fires your Twilio/Vonage client — the 2.0.2 Guzzle patch implies the package has some HTTP client path of its own, so check the source for an existing transport before you write one. The README's wording is ambiguous and the source settles it.

7. Test with Mail::fake() and database assertions.

Mail::fake();
$this->post('/otp/send', ['email' => '[email protected]'])->assertSessionHas('otp_unique_id');
$this->assertDatabaseCount('otps', 1);

Because the state is in a real table, you can assert on attempt counts directly — which is the only reliable way to prove the retry blocking works in your configuration.

8. Schedule cleanup.

The otps table grows forever. Either the package provides a prune command or you write a five-line scheduled job that deletes rows past expires_at. Do it at install time, not after the table hits a million rows.

9. Include the package in your upgrade cadence.

Since 2.0.2 exists specifically to track a Guzzle change, treat this dependency as one that needs composer update alongside the rest of your stack — don't pin it and forget it for two years, because the failure mode is an exception on send, not a clean "incompatible" error.

Gotchas and Tips

Guzzle version coupling — the 2.0.2 fix, and what it means for you. Older versions called getResponseBodySummary() on the Guzzle response, which newer Guzzle releases removed. Symptom was a confusing BadMethodCallException/undefined-method error during send, which masked the actual HTTP failure from your SMS or mail provider. On 2.0.2 this is fixed. Practical consequences:

  • Upgrade the package and Guzzle together (composer update ferdous/laravel-otp-validate guzzlehttp/guzzle) — don't leave a half-upgraded lockfile.
  • If you're pinned to old Guzzle for another dependency, 2.0.2 is still fine; the fix removes the call rather than requiring a version.
  • If you're on < 2.0.2 and see that exception, you've been losing the real error message. Log the underlying response yourself in the wrapper before assuming the OTP service is down.

OTPs are stored in plaintext. The migration writes the code into a column as-is. Anyone with read access to your DB — a leaked backup, a read-replica, a support engineer — can read live codes. If that's unacceptable, hash the code before verification (requires patching or a custom driver) or at minimum keep the expiry window very short (2–5 minutes) so the exposure window is tiny.

Expiry comparisons are a classic timezone trap. The package compares the stored expiry to "now" using whatever your app's timezone is. If you change app.timezone after rows exist, you can get OTPs that never expire or expire instantly. Set the timezone once, at project creation, and leave it.

Config caching bites on first deploy. php artisan config:cache will freeze your channel/template settings. Every deploy that changes config/otpvalidator.php needs a cache rebuild — most deploy scripts do this, but note the failure mode: your template edits silently don't apply. After a package upgrade, always php artisan config:clear and php artisan optimize:clear — an upgrade can add or rename config keys, and a stale cache will silently hand the new source the old array. When something "isn't working in production but works locally," that's step one.

Framework constraint — verify, but don't assume it's dead. 2.0.2 confirms the package is being maintained against current dependency versions, so the "abandoned since 2022" concern is retired. Auto-discovery and the service provider should boot on Laravel 10/11/12. Still smoke-test send → verify → resend → blocked-on-retry on your actual Laravel major before production, and pin the version in composer.json. Don't rely on the package's own test suite; run your own.

Migration collisions. If you already have an otps table (common — people build this once, forget, and rebuild), the published migration will fail. Check php artisan migrate:status and rename or reconcile before running. Same caution for 2.x: if a major release altered the schema, the published migration may not match your existing 1.x table — diff before running it on data you care about.

Failure modes are under-documented — instrument them. You need to distinguish "wrong code," "expired," "max retries hit," and "max resends hit," because the UX differs and the security signal differs. If verify() returns a bare boolean, you'll be guessing. Log the outcome with the identifier hash and timestamp at your wrapper layer, and alert on a spike in "max retries hit" for a single identifier — that's the signature of an OTP brute-force, and this package won't tell you about it.

Multi-tenant and multi-guard scoping. The identifier is whatever string you pass in. If two tenants can both use [email protected], their OTP records can collide or cross-validate depending on how the lookup is done. Prefix the identifier with a tenant ID (tenant:{$id}:{$email}) inside your wrapper. Same trick if you run OTP flows for both login and contact-verification for the same address — namespace them so one flow's retry counter doesn't block the other.

Extension points worth knowing: the config's channel/driver selection (for plugging in your SMS gateway), the template setting (for i18n), and — if the source exposes them — events around send/verify, which are the cleanest place to hook audit logging without touching the package. If no events exist, your wrapper class is the extension point; that's the whole reason to build it first.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin