signaturetech/laravel-otp
A Laravel package for generating and verifying one-time passwords (OTPs), helping you add secure OTP-based authentication, login, and verification flows with configurable codes and expiration.
Install and verify auto-discovery.
composer require signaturetech/laravel-otp
The package is small enough that Laravel's package auto-discovery should register its service provider and facade. Before writing any code, open vendor/signaturetech/laravel-otp/ and read three things in order:
composer.json → look at the extra.laravel block to confirm what gets registered (provider, facade alias, config tag).src/ → identify the main class (typically Otp, OtpService, or a trait) and whether it uses the cache, a database table, or both.README.md → the exact method names, since a "super simple" package often exposes only generate() and validate().Publish config if a tag exists.
php artisan vendor:publish --tag=otp-config
If no tag is listed in the README, check the provider's boot() method — a tag-less publishes() call means you'll need to guess the tag or copy the config manually. Set expiry (seconds/minutes) and the cache store explicitly rather than relying on the default.
First use case: phone or email verification at registration. The most common flow is a two-step signup — collect the identifier, generate and send an OTP, then validate on the next request. Start with the smallest viable flow in a controller or FormRequest and expand from there:
use SignatureTech\Otp\Facades\Otp; // verify the actual namespace in src/
$code = Otp::generate($request->phone);
// send $code via SMS/notification
Sanity check the storage backend on day one. If it's cache-backed, run php artisan tinker and inspect the key after generating an OTP (Cache::get(...) or redis-cli KEYS '*otp*'). If it's DB-backed, check for a published migration. Knowing where the OTP lives determines how you debug everything else.
Wrap the package behind your own service. Given the package's single-purpose scope, avoid sprinkling Otp::generate() across controllers. Create a thin OtpService / VerificationCode class that owns generation, sending (Notification or Mail), rate limiting, and invalidation. This gives you one place to swap the library out later and one place to add logic the package doesn't cover (e.g., attempt counters).
Generation + send flow. Keep generation synchronous but dispatch the delivery (SMS/Mail) via a queued Notification. Cache the code with a TTL that matches your config expiry, and key it on a normalized identifier:
$code = Otp::generate($identifier); // returns the code to send
$user->notify(new SendOtpNotification($code));
Normalize the identifier (lowercase email, strip phone formatting) before passing it in, since the package almost certainly keys the stored value directly.
Validation with attempt limits. A validate call returns a boolean; wrap it so failed attempts are counted in the cache and the identifier is locked out after N failures. The package won't do this for you.
if (! Otp::validate($identifier, $request->code)) {
Cache::increment("otp:attempts:{$identifier}");
// throw validation error
}
Invalidate on success. If the package doesn't delete the code after a successful validate (check the source — many don't), call Cache::forget() or whatever forget/consume method exists. Otherwise a replayed code works until TTL expiry.
Testing. Freeze time with $this->travelTo() or Carbon::setTestNow() around expiry assertions, and use Cache::flush() in setUp() (or per-test) so OTPs don't leak between tests. If the package exposes a facade, Otp::shouldReceive(...) makes it easy to stub in feature tests where you don't want to exercise the real generation.
Integration tips.
FormRequest::validate or a custom rule (OtpCodeRule) so both API and web controllers share the same verification semantics.RateLimiter::attempt() keyed on IP + identifier so a bad actor can't spam SMS costs.Cache::put() with the default driver, your OTPs vanish on php artisan cache:clear and behave differently between file, array, and redis. Pin the store in the published config (or via Cache::store('redis') inside your wrapper) so dev and prod match.php artisan config:cache in CI/prod will silently fall back to vendor defaults. Publish and commit the config.email and phone for the same user, the underlying key may clash unless the package namespaces by type. Prefix your identifier (e.g., email:[email protected]) before passing it in to avoid one overwriting the other.validate() loop is a brute-force oracle since OTPs are short numeric codes.validate() delete on success? Does generate() overwrite or return the existing code? Is comparison hash_equals or ===? These answers change your wrapper's behavior materially.otp:* in Redis), confirm the identifier was normalized identically in generate and validate, check expiry units (seconds vs minutes is a classic bug), and confirm TTL hasn't elapsed due to timezone/now() drift.validate() is too rigid, bind your own implementation against its interface (if one exists) or — realistically for a package this small — fork the ~100 lines into app/Support/Otp.php and own it. That's often cheaper than waiting on a vendor.How can I help you explore Laravel packages today?