用 PHP 发送短信验证码
原生 PHP、Laravel 和 WordPress

生成验证码、安全保存、发到马来西亚手机号、核对用户输入 —— 可以直接贴上的完整代码,并带上防止机器人刷短信费的限制。

整体流程

短信验证码流程里,用户发两个请求,你的服务器发一个:

  1. 用户输入手机号。服务器检查限制,生成随机验证码,把它的哈希值连同过期时间存起来,再通过 API 发送短信。
  2. 用户输入验证码。服务器和存起来的哈希值比对,记一次尝试次数,比对成功就把这个验证码标记为已使用。

HantarSMS 只负责发送。验证码除了写在短信里,从不离开你的系统,所以以后换服务商也不用改核对逻辑。下面所有示例都只发马来西亚号码;API 接受 0123456789、60123456789 或 +60123456789。代码里的注释和短信内容保留英文,短信内容可以换成中文,但要控制在 66 个字以内(见 OTP 短信模板)。

原生 PHP

PHP 8,使用 cURL 和 PDO(MySQL)。密钥放在环境变量里,不要写进代码。先建一张表:

CREATE TABLE otp_codes (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  phone      VARCHAR(15)  NOT NULL,
  code_hash  VARCHAR(255) NOT NULL,
  expires_at DATETIME     NOT NULL,
  attempts   TINYINT      NOT NULL DEFAULT 0,
  created_at DATETIME     NOT NULL,
  INDEX (phone, created_at)
);

发送函数。Idempotency-Key 就是让重试变安全的关键:

<?php
function hantarsms_send(string $to, string $text, string $idemKey): array {
    $ch = curl_init('https://hantarsms.com/api/v1/messages');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'X-API-Key: ' . getenv('HANTARSMS_KEY'),
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idemKey,
            'User-Agent: myshop/1.0',
        ],
        CURLOPT_POSTFIELDS => json_encode(['to' => $to, 'text' => $text, 'reference' => 'otp']),
    ]);
    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);   // 0 = timeout / no connection
    curl_close($ch);
    return ['status' => $status, 'body' => $body === false ? null : json_decode($body, true)];
}

// Normalise to 60XXXXXXXXX so rate limits are counted per real number.
function my_phone(string $raw): ?string {
    $n = preg_replace('/\D/', '', $raw);
    if (str_starts_with($n, '0')) $n = '6' . $n;
    return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}

签发验证码 —— 先检查限制,再发短信:

function issue_otp(PDO $db, string $phone): bool {
    // One code per 60 seconds and five per hour for each number.
    $q = $db->prepare("SELECT SUM(created_at > NOW() - INTERVAL 60 SECOND) AS last_min, COUNT(*) AS last_hour
                       FROM otp_codes WHERE phone = ? AND created_at > NOW() - INTERVAL 1 HOUR");
    $q->execute([$phone]);
    $r = $q->fetch(PDO::FETCH_ASSOC);
    if ($r['last_min'] > 0 || $r['last_hour'] >= 5) return false;

    $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    $db->prepare("INSERT INTO otp_codes (phone, code_hash, expires_at, created_at)
                  VALUES (?, ?, NOW() + INTERVAL 5 MINUTE, NOW())")
       ->execute([$phone, password_hash($code, PASSWORD_DEFAULT)]);
    $key  = 'otp-' . $db->lastInsertId();
    $text = "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.";

    $res = hantarsms_send($phone, $text, $key);
    if ($res['status'] === 0 || $res['status'] >= 500) {
        // Timed out or server error: retry once with the SAME key.
        // If the first request actually went through, this returns it instead of sending again.
        $res = hantarsms_send($phone, $text, $key);
    }
    return $res['status'] === 201;
}

核对用户输入:

function check_otp(PDO $db, string $phone, string $input): bool {
    $q = $db->prepare("SELECT id, code_hash, attempts FROM otp_codes
                       WHERE phone = ? AND expires_at > NOW() ORDER BY id DESC LIMIT 1");
    $q->execute([$phone]);
    $row = $q->fetch(PDO::FETCH_ASSOC);
    if (!$row || $row['attempts'] >= 5) return false;          // no live code, or too many guesses

    $db->prepare("UPDATE otp_codes SET attempts = attempts + 1 WHERE id = ?")->execute([$row['id']]);
    if (!password_verify($input, $row['code_hash'])) return false;

    $db->prepare("UPDATE otp_codes SET expires_at = NOW() WHERE id = ?")->execute([$row['id']]);  // single use
    return true;
}

为什么要存哈希:数据库外泄时,有效的验证码不会跟着外泄。为什么要计尝试次数:不计的话,脚本可以对一个号码把 100 万个六位数验证码全试一遍。

Laravel

同样的流程,用 Laravel 的 HTTP 客户端。把密钥写进 .env 的 HANTARSMS_KEY,再通过 config/services.php 读取。只在连接错误时重试 —— 重试 402(余额不足)或 422(号码不对)不会得到不同的答案。

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

$res = Http::withHeaders([
        'X-API-Key'       => config('services.hantarsms.key'),
        'Idempotency-Key' => "otp-{$otp->id}",
    ])
    ->timeout(15)
    ->retry(2, 500, fn ($e) => $e instanceof ConnectionException, throw: false)
    ->post('https://hantarsms.com/api/v1/messages', [
        'to'   => $otp->phone,
        'text' => "MyShop: your verification code is {$code}. Valid for 5 minutes. Do not share it.",
    ]);

if ($res->status() !== 201) {
    Log::warning('OTP SMS not sent', ['error' => $res->json('error.code')]);
}

用 Hash::make() 保存验证码、Hash::check() 核对,每个号码和每个 IP 的限次用 Laravel 的 RateLimiter。

WordPress

想用现成插件?HantarSMS WordPress 插件有验证码登录、WooCommerce 订单短信和营销群发。想自己写的话,把下面这段存成 wp-content/plugins/myshop-otp/myshop-otp.php,在 wp-config.php 里加上 define('HANTARSMS_KEY', '...');,然后启用。它会加一个 REST 接口给你的注册或结账表单调用,并提供一个在表单提交时核对验证码的函数。

<?php
/**
 * Plugin Name: MyShop SMS OTP
 */

function myshop_phone(string $raw): ?string {
    $n = preg_replace('/\D/', '', $raw);
    if (str_starts_with($n, '0')) $n = '6' . $n;
    return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}

add_action('rest_api_init', function () {
    register_rest_route('myshop/v1', '/otp', [
        'methods'             => 'POST',
        'permission_callback' => '__return_true',
        'callback'            => 'myshop_send_otp',
    ]);
});

function myshop_send_otp(WP_REST_Request $req) {
    $phone = myshop_phone((string) $req->get_param('phone'));
    if (!$phone) {
        return new WP_Error('bad_phone', 'Enter a Malaysian mobile number.', ['status' => 400]);
    }
    $ipKey = 'otp_ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
    if (get_transient("otp_wait_$phone") || (int) get_transient($ipKey) >= 10) {
        return new WP_Error('too_many', 'Please wait before requesting another code.', ['status' => 429]);
    }

    $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
    set_transient("otp_code_$phone", [
        'hash'  => wp_hash_password($code),
        'exp'   => time() + 5 * MINUTE_IN_SECONDS,
        'tries' => 0,
    ], 5 * MINUTE_IN_SECONDS);
    set_transient("otp_wait_$phone", 1, MINUTE_IN_SECONDS);
    set_transient($ipKey, (int) get_transient($ipKey) + 1, HOUR_IN_SECONDS);

    $res = wp_remote_post('https://hantarsms.com/api/v1/messages', [
        'timeout' => 15,
        'headers' => [
            'X-API-Key'       => HANTARSMS_KEY,
            'Content-Type'    => 'application/json',
            'Idempotency-Key' => 'otp-' . wp_generate_uuid4(),
        ],
        'body' => wp_json_encode([
            'to'   => $phone,
            'text' => "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.",
        ]),
    ]);
    if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 201) {
        return new WP_Error('send_failed', 'Could not send the code. Try again in a minute.', ['status' => 502]);
    }
    return ['sent' => true];
}

// Call this when the form is submitted.
function myshop_check_otp(string $rawPhone, string $input): bool {
    $phone = myshop_phone($rawPhone);
    $row   = $phone ? get_transient("otp_code_$phone") : false;
    if (!$row || $row['tries'] >= 5 || time() > $row['exp']) return false;

    $row['tries']++;
    set_transient("otp_code_$phone", $row, max(1, $row['exp'] - time()));   // keep the original expiry
    if (!wp_check_password($input, $row['hash'])) return false;

    delete_transient("otp_code_$phone");                                    // single use
    return true;
}

两个 WordPress 特有的注意点。网站在 Cloudflare 或其他代理后面的话,REMOTE_ADDR 是代理的地址,按 IP 限次要从代理的请求头里取访客真实 IP。另外给调用这个接口的表单加上 CAPTCHA —— 接口是公开的,每次通过限制的调用都要花一条短信的钱。

处理 API 的返回

返回含义你的代码该怎么做
201已接收并扣费显示输入验证码的页面
402 insufficient_balance账户额度用完了提醒自己充值;告诉用户稍后再试
422 no_valid_recipients不是马来西亚手机号请用户检查号码
429 rate_limit_exceeded超过这把密钥的发送上限等 Retry-After;如果这是你真实的高峰,请我们调高上限
超时或 5xx结果未知用同一个 Idempotency-Key 重试

完整错误码列表、查询送达状态的方法,以及 Python、Node.js、Java 示例,见 API 文档。现成的短信写法见 OTP 短信模板。

PHP / WordPress 验证码常见问题

有 WordPress 或 WooCommerce 插件吗?

有:免费的 HantarSMS WordPress 插件提供短信验证码登录、WooCommerce 订单短信和营销群发,还提供 hantarsms_otp_send() 和 hantarsms_otp_verify() 给你自己的表单用。如果你想每一行代码都自己掌握,本页那个简短插件就涵盖了验证码部分。

验证码可以明文保存吗?

不要。保存哈希值(PHP 用 password_hash、Laravel 用 Hash::make、WordPress 用 wp_hash_password),再用对应的验证函数比对。这样数据库外泄时,有效的验证码不会跟着外泄。

API 请求超时怎么办?

用同一个 Idempotency-Key 重试。如果第一次请求其实已经成功,API 会返回那次的结果,而不是再发第二条短信,所以用户只收到一个验证码、你只付一次钱。

怎么防止机器人刷几千条验证码?

按手机号和 IP 地址限次,公开表单加 CAPTCHA,只保留需要用到的额度。每把 API 密钥默认也有每分钟 60 个请求的发送上限。

这些示例在马来西亚以外能用吗?

代码本身可以,但 HantarSMS 只发马来西亚手机号,所以示例会拒绝所有不是 601 开头的号码。

拿一把 API 密钥

注册,在控制台建密钥,WhatsApp 领 RM1 试用额度 —— 够你用自己的手机把这些示例跑一遍。