Skip to content

Instantly share code, notes, and snippets.

@erdum
Last active December 28, 2025 14:05
Show Gist options
  • Select an option

  • Save erdum/871658c3283828f5bcb49715f4bc0bbf to your computer and use it in GitHub Desktop.

Select an option

Save erdum/871658c3283828f5bcb49715f4bc0bbf to your computer and use it in GitHub Desktop.
OTP abuse protection mechanism with versatile delivery option for Laravel
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use App\Models\Otp;
use Carbon\Carbon;
class OtpService
{
public static function send(
string $identifier,
string $value,
callable $send
): mixed {
$expiry_min = config('app.otp.expiry_duration');
$retry_min = config('app.otp.retry_duration');
$max_retries = config('app.otp.retries');
$otp = Otp::lockForUpdate()
->where('expires_at', '>', now())
->where('identifier', $identifier)
->first();
if ($otp) {
$cooldown_ends = $otp->sent_at->addMinutes($retry_min);
if ($cooldown_ends->isFuture()) {
$seconds = now()->diffInSeconds($cooldown_ends);
throw new \Exception("Please wait {$seconds} seconds before requesting another OTP.");
}
if ($otp->retries >= $max_retries) {
$reset_time = $otp->sent_at->addMinutes(
$retry_min * ($max_retries + 1)
);
$diff = now()->diffInMinutes($reset_time);
throw new \Exception("Too many attempts. Please try again in {$diff} minutes.");
}
$otp->value = $value;
$otp->retries += 1;
$otp->sent_at = now();
$otp->expires_at = now()->addMinutes($expiry_min);
$otp->verified_at = null;
$otp->save();
} else {
$otp = new Otp;
$otp->identifier = $identifier;
$otp->value = $value;
$otp->retries = 1;
$otp->sent_at = now();
$otp->expires_at = now()->addMinutes($expiry_min);
$otp->verified_at = null;
$otp->save();
}
return $send($value);
}
public static function verify(string $value): bool|string
{
$otp = Otp::lockForUpdate()
->where('expires_at', '>', now())
->where('value', $value)
->whereNull('verified_at')
->first();
if (! $otp) return false;
$otp->verified_at = now();
$otp->save();
return $otp->identifier;
}
public static function cleanup(): int
{
return Otp::where('expires_at', '<', now()->subHours(24))->delete();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment