Send OTP Emails in Laravel (2026) with a Plain HTTP API
Build signup/login OTP in Laravel: random_int, HMAC-hashed codes in Cache or Eloquent, rate limits, Http:: or Guzzle to POST /emails, and Idempotency-Key—prefer HTTP over Mail:: SMTP; Rovela delivers only.
Why Laravel + OTP codes (not Mail:: SMTP or a magic-link SDK)
Laravel already gives signup and login a familiar shape: routes, Form Requests, middleware (throttle), Cache or Eloquent, and queues. You do not need a Rovela Laravel package, an “OTP product” endpoint, or a custom auth guard that pretends the email API is an identity provider.
What you need is the recipe in Email verification codes: your app mints a six-digit code with random_int, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text via Laravel’s Http:: client (or Guzzle). Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.
Rovela does not provide an OTP feature. Laravel owns cryptography, the cache key or DB row, resend throttling, redeem logic, and the session / Sanctum token. Rovela only delivers the mail.
Prefer the HTTP API over Mail:: + SMTP for OTP. Cloud hosts often restrict outbound 25/465/587; HTTPS 443 stays open. You also get a message id, webhook events, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. Keep SMTP for legacy Mailable paths if you must—new auth digits should call POST /emails.
This article is Laravel / PHP focused (11.x-friendly): controllers for “send code” and “verify code,” a thin Rovela client, Cache or Eloquent storage, and rate limits. It is not a magic-link walkthrough—for those see Rails, Next.js, NestJS, and FastAPI. Sibling OTP posts: Express.js and Django. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email. Generic PHP/Laravel transport (not OTP): Sending email in PHP and Laravel—this post does not rehash Mailables, SMTP DSN setup, or one-off “hello world” sends.
What you own vs what Rovela sends
Your Laravel app owns:
random_int(0, 999_999)→ six digits, zero-padded withsprintf(neverrand()/mt_rand())- HMAC-SHA256 of
email + ":" + codewith a server pepper (CODE_PEPPER) viahash_hmac - Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
- Upsert by normalized email so a new send invalidates the previous code (Cache key or Eloquent row)
- Constant-time compare on redeem (
hash_equals); forget / delete on success - Identical public responses whether or not you want to reveal account existence (product choice)
- Rate limits on send and verify routes (
throttlemiddleware and/orRateLimiter)
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin.env—never commit it, never put it in front-end Vite env, never log it)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Env and a tiny Rovela client (Http::)
# .env (illustrative)
ROVELA_API_KEY=re_...
CODE_PEPPER=long-random-secret
OTP_FROM="Acme <security@mail.acme.com>"
OTP_TTL_MINUTES=10
OTP_RESEND_COOLDOWN_SEC=60
OTP_MAX_ATTEMPTS=5
<?php
// app/Services/RovelaMail.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class RovelaMail
{
public function send(
string $to,
string $subject,
string $html,
string $text,
string $idempotencyKey,
): array {
$apiKey = config('services.rovela.key'); // maps ROVELA_API_KEY
$from = config('services.rovela.from');
$response = Http::withToken($apiKey)
->withHeaders([
'Idempotency-Key' => $idempotencyKey,
'Accept' => 'application/json',
])
->timeout(15)
->post('https://api.rovela.dev/emails', [
'from' => $from,
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
]);
if ($response->failed()) {
throw new RuntimeException(
'Rovela send failed: '.$response->status().' '.$response->body()
);
}
return $response->json();
}
}
<?php
// config/services.php (snippet)
'rovela' => [
'key' => env('ROVELA_API_KEY'),
'from' => env('OTP_FROM', 'Acme <security@mail.acme.com>'),
],
Http::withToken sets Authorization: Bearer …. The same JSON body works with Guzzle if you are outside the Laravel container. Wire secrets like any other server credential (Authentication, Quick Start).
Hash at rest: random_int + HMAC, not bare SHA-1
Six digits are only a million possibilities. A leaked table of bare hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server.
<?php
// app/Services/OtpCrypto.php
namespace App\Services;
class OtpCrypto
{
public static function normalizeEmail(string $raw): string
{
return strtolower(trim($raw));
}
public static function mintCode(): string
{
// CSPRNG — rand() / mt_rand() are not for secrets
return sprintf('%06d', random_int(0, 999_999));
}
public static function hashCode(string $email, string $code): string
{
$pepper = (string) env('CODE_PEPPER');
return hash_hmac('sha256', $email.':'.$code, $pepper);
}
public static function safeEqual(string $a, string $b): bool
{
return hash_equals($a, $b);
}
}
Suggested row shape (one active code per email), matching the verification-code example:
CREATE TABLE verification_codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Eloquent sketch:
<?php
// app/Models/VerificationCode.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VerificationCode extends Model
{
protected $primaryKey = 'email';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'email', 'code_hash', 'expires_at', 'attempts', 'sent_at',
];
protected $casts = [
'expires_at' => 'datetime',
'sent_at' => 'datetime',
'attempts' => 'integer',
];
}
Cache alternative: store {code_hash, attempts, sent_at} under otp:{email} with TTL = expiry (Cache::put). Resend cooldown can be a second key otp:cooldown:{email}. Redis/cache is fine for short-lived codes; use the DB when you need auditability across workers without shared Redis.
Service: send + verify (Cache version)
<?php
// app/Services/OtpService.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
class OtpService
{
public function __construct(private RovelaMail $mail) {}
public function send(string $rawEmail): array
{
$email = OtpCrypto::normalizeEmail($rawEmail);
$ttlMin = (int) env('OTP_TTL_MINUTES', 10);
$cooldown = (int) env('OTP_RESEND_COOLDOWN_SEC', 60);
$key = 'otp:'.$email;
$coolKey = 'otp:cooldown:'.$email;
if (Cache::has($coolKey)) {
return ['ok' => false, 'error' => 'too_soon'];
}
$code = OtpCrypto::mintCode();
$codeHash = OtpCrypto::hashCode($email, $code);
$now = Carbon::now();
Cache::put($key, [
'code_hash' => $codeHash,
'attempts' => 0,
'sent_at' => $now->toIso8601String(),
], now()->addMinutes($ttlMin));
Cache::put($coolKey, 1, now()->addSeconds($cooldown));
// Stable per logical send: hash prefix. New code ⇒ new key.
$idempotencyKey = 'otp-'.substr($codeHash, 0, 32);
$this->mail->send(
to: $email,
subject: "Your Acme code: {$code}",
html: '<p>Your verification code is</p>'
.'<p style="font-size:28px;font-weight:600;letter-spacing:4px">'
.e($code)
.'</p>'
."<p>It expires in {$ttlMin} minutes. "
.'If you did not ask for it, ignore this email.</p>',
text: "Your verification code is {$code}. "
."It expires in {$ttlMin} minutes. "
.'If you did not ask for it, ignore this email.',
idempotencyKey: $idempotencyKey,
);
return [
'ok' => true,
'message' => 'If that address can receive mail, a code is on its way.',
];
}
public function verify(string $rawEmail, string $rawCode): array
{
$email = OtpCrypto::normalizeEmail($rawEmail);
$code = trim($rawCode);
$maxAttempts = (int) env('OTP_MAX_ATTEMPTS', 5);
$key = 'otp:'.$email;
if (! (strlen($code) === 6 && ctype_digit($code))) {
return ['ok' => false, 'error' => 'invalid'];
}
$row = Cache::get($key);
if (! is_array($row) || empty($row['code_hash'])) {
return ['ok' => false, 'error' => 'invalid'];
}
if ((int) ($row['attempts'] ?? 0) >= $maxAttempts) {
return ['ok' => false, 'error' => 'locked'];
}
$match = OtpCrypto::safeEqual(
OtpCrypto::hashCode($email, $code),
(string) $row['code_hash'],
);
if (! $match) {
$row['attempts'] = (int) ($row['attempts'] ?? 0) + 1;
// Keep remaining TTL roughly intact
Cache::put($key, $row, now()->addMinutes((int) env('OTP_TTL_MINUTES', 10)));
return ['ok' => false, 'error' => 'invalid'];
}
Cache::forget($key);
// issue session / Sanctum token here
return ['ok' => true];
}
}
Putting the code in the subject helps lock-screen previews—fine for many products, skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).
For an Eloquent path, updateOrCreate on email, bump attempts inside a DB transaction with lockForUpdate(), and delete() on success—same crypto and Rovela client as above.
Controllers + rate limits
<?php
// app/Http/Controllers/OtpController.php
namespace App\Http\Controllers;
use App\Services\OtpService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OtpController extends Controller
{
public function send(Request $request, OtpService $otp): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email:rfc'],
]);
$result = $otp->send($data['email']);
if (! ($result['ok'] ?? false) && ($result['error'] ?? null) === 'too_soon') {
return response()->json(['error' => 'too_soon'], 429);
}
return response()->json([
'message' => $result['message'] ?? 'ok',
]);
}
public function verify(Request $request, OtpService $otp): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email:rfc'],
'code' => ['required', 'string'],
]);
$result = $otp->verify($data['email'], $data['code']);
if (! ($result['ok'] ?? false)) {
$err = $result['error'] ?? 'invalid';
$status = in_array($err, ['locked', 'too_soon'], true) ? 429 : 400;
return response()->json(['error' => $err], $status);
}
// Auth::login(...) or issue Sanctum token
return response()->json(['ok' => true]);
}
}
<?php
// routes/api.php (snippet)
use App\Http\Controllers\OtpController;
Route::post('/auth/otp/send', [OtpController::class, 'send'])
->middleware('throttle:10,1');
Route::post('/auth/otp/verify', [OtpController::class, 'verify'])
->middleware('throttle:20,1');
Per-email cooldown inside the service and per-IP throttle / RateLimiter solve different problems—use both. Hardening checklist: Email OTP security.
Idempotency-Key: retries without double codes
Queue workers, HTTP client retries, Octane workers, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.
- Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
- Derive the key from the code hash (
otp-+ first 32 hex chars) so one logical mint maps to one queued message. - A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-laravel-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "you@example.com",
"subject": "Your Acme code: 482913",
"html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
"text": "Your verification code is 482913. Expires in 10 minutes."
}'
Why not Mail:: / SMTP for OTP
Http:: → POST /emails (this post) | Mail:: + SMTP | |
|---|---|---|
| Transport | HTTPS 443 | 465 / 587 (25 often blocked) |
| Idempotency | Idempotency-Key header | App must dedupe |
| Feedback | Message id + webhooks | Bounce DSNs later |
| Serverless / PaaS | Port 443 always open | SMTP often restricted |
| OTP fit | JSON html + text | MIME via Mailable |
Use Mailables when you already depend on SMTP. For new signup/login digits, call the HTTP API. Broader PHP transport patterns (cURL, HTTP client, webhook verify) live in Sending email in PHP and Laravel.
Deliverability notes for auth digits (not magic URLs)
OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.
If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.
OTP codes vs magic links in Laravel
| Six-digit OTP (this post) | Magic link | |
|---|---|---|
| User action | Type digits | Click URL |
| Secret entropy | Low → HMAC + pepper required | High URL token → SHA-256 of token is usually enough |
| Docs shape | Verification codes | Password reset / link pattern |
| Laravel surface | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| Click tracking | Optional concern | Prefer off on the security sending domain |
Both call the same POST /emails. Keep separate models/services—do not reuse an OTP cache entry as a magic-link token. For a Rails magic-link stack, see Magic link login in Rails.
Checklist before you ship
random_int+ HMAC pepper; never store raw codes; never userand()/mt_rand().- ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP
throttle. - Upsert / overwrite invalidates the previous code; verify forgets/deletes on success.
htmlandtext; optional subject code for previews.ROVELA_API_KEY(sending_access) only on the server (notVITE_*).Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- Prefer
Http::over SMTP for OTP; verified from-domain; spam checklist if inbox placement slips.
Related reading
- Docs: Email verification codes · POST /emails · Quick Start · Authentication.
- Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
- UX choice: Email verification: code vs link.
- Deliverability: Why OTP and magic-link emails go to spam.
- Framework siblings: Express OTP · Django OTP · magic-link Rails / Next.js / NestJS / FastAPI.
- PHP transport only: Sending email in PHP and Laravel.
Bottom line: put signup/login OTP in Laravel controllers that mint random_int codes, HMAC them at rest (Cache or Eloquent), rate-limit send/verify, and call one Http:: POST to Rovela with an Idempotency-Key—prefer that over Mail:: SMTP, and never pretend the email API is your auth server.