Laravel 13 可以轻松集成多种短信服务,本文介绍如何构建完整的短信发送系统。

短信配置

多渠道配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php

return [
'default' => env('SMS_DRIVER', 'twilio'),

'drivers' => [
'twilio' => [
'sid' => env('TWILIO_SID'),
'token' => env('TWILIO_TOKEN'),
'from' => env('TWILIO_FROM'),
],

'nexmo' => [
'key' => env('NEXMO_KEY'),
'secret' => env('NEXMO_SECRET'),
'from' => env('NEXMO_FROM'),
],

'aliyun' => [
'access_key' => env('ALIYUN_ACCESS_KEY'),
'access_secret' => env('ALIYUN_ACCESS_SECRET'),
'sign_name' => env('ALIYUN_SMS_SIGN'),
'region' => env('ALIYUN_SMS_REGION', 'cn-hangzhou'),
],

'tencent' => [
'secret_id' => env('TENCENT_SECRET_ID'),
'secret_key' => env('TENCENT_SECRET_KEY'),
'app_id' => env('TENCENT_SMS_APP_ID'),
'sign_name' => env('TENCENT_SMS_SIGN'),
],
],

'rate_limits' => [
'per_minute' => 60,
'per_hour' => 500,
'per_day' => 2000,
],
];

短信服务

基础短信接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

namespace App\Services\SMS;

interface SMSDriverInterface
{
public function send(string $to, string $message): SMSResult;
public function sendTemplate(string $to, string $templateId, array $params): SMSResult;
public function sendBatch(array $recipients, string $message): array;
}

class SMSResult
{
public function __construct(
public bool $success,
public ?string $messageId = null,
public ?string $errorCode = null,
public ?string $errorMessage = null,
public array $metadata = []
) {}

public function isSuccess(): bool
{
return $this->success;
}
}

Twilio 驱动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php

namespace App\Services\SMS\Drivers;

use App\Services\SMS\SMSDriverInterface;
use App\Services\SMS\SMSResult;
use Twilio\Rest\Client;

class TwilioDriver implements SMSDriverInterface
{
protected Client $client;
protected string $from;

public function __construct(array $config)
{
$this->client = new Client($config['sid'], $config['token']);
$this->from = $config['from'];
}

public function send(string $to, string $message): SMSResult
{
try {
$result = $this->client->messages->create($to, [
'from' => $this->from,
'body' => $message,
]);

return new SMSResult(
success: $result->status !== 'failed',
messageId: $result->sid,
metadata: [
'status' => $result->status,
'to' => $result->to,
]
);
} catch (\Throwable $e) {
return new SMSResult(
success: false,
errorCode: $e->getCode(),
errorMessage: $e->getMessage()
);
}
}

public function sendTemplate(string $to, string $templateId, array $params): SMSResult
{
$message = $this->compileTemplate($templateId, $params);
return $this->send($to, $message);
}

public function sendBatch(array $recipients, string $message): array
{
$results = [];

foreach ($recipients as $recipient) {
$results[$recipient] = $this->send($recipient, $message);
}

return $results;
}

protected function compileTemplate(string $templateId, array $params): string
{
return implode(' ', $params);
}
}

阿里云短信驱动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php

namespace App\Services\SMS\Drivers;

use AlibabaCloud\SDK\Dysmsapi\V20170525\Dysmsapi;
use AlibabaCloud\SDK\Dysmsapi\V20170525\Models\SendSmsRequest;
use App\Services\SMS\SMSDriverInterface;
use App\Services\SMS\SMSResult;
use Darabonba\OpenApi\Models\Config;

class AliyunDriver implements SMSDriverInterface
{
protected Dysmsapi $client;
protected string $signName;

public function __construct(array $config)
{
$this->signName = $config['sign_name'];

$configObj = new Config([
'accessKeyId' => $config['access_key'],
'accessKeySecret' => $config['access_secret'],
'regionId' => $config['region'] ?? 'cn-hangzhou',
]);

$this->client = new Dysmsapi($configObj);
}

public function send(string $to, string $message): SMSResult
{
return $this->sendTemplate($to, 'default', ['message' => $message]);
}

public function sendTemplate(string $to, string $templateId, array $params): SMSResult
{
try {
$request = new SendSmsRequest([
'phoneNumbers' => $to,
'signName' => $this->signName,
'templateCode' => $templateId,
'templateParam' => json_encode($params),
]);

$result = $this->client->sendSms($request);

return new SMSResult(
success: $result->body->code === 'OK',
messageId: $result->body->bizId,
errorCode: $result->body->code,
errorMessage: $result->body->message,
metadata: [
'request_id' => $result->body->requestId,
]
);
} catch (\Throwable $e) {
return new SMSResult(
success: false,
errorCode: $e->getCode(),
errorMessage: $e->getMessage()
);
}
}

public function sendBatch(array $recipients, string $message): array
{
$results = [];

foreach ($recipients as $recipient) {
$results[$recipient] = $this->send($recipient, $message);
}

return $results;
}
}

短信服务管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php

namespace App\Services\SMS;

use App\Services\SMS\Drivers\AliyunDriver;
use App\Services\SMS\Drivers\TwilioDriver;

class SMSManager
{
protected array $drivers = [];
protected array $config;

public function __construct(array $config)
{
$this->config = $config;
}

public function driver(string $name = null): SMSDriverInterface
{
$name = $name ?? $this->config['default'];

if (!isset($this->drivers[$name])) {
$this->drivers[$name] = $this->createDriver($name);
}

return $this->drivers[$name];
}

protected function createDriver(string $name): SMSDriverInterface
{
$config = $this->config['drivers'][$name] ?? [];

return match ($name) {
'twilio' => new TwilioDriver($config),
'aliyun' => new AliyunDriver($config),
default => throw new \InvalidArgumentException("SMS driver [{$name}] not supported"),
};
}

public function send(string $to, string $message): SMSResult
{
return $this->driver()->send($to, $message);
}

public function sendTemplate(string $to, string $templateId, array $params): SMSResult
{
return $this->driver()->sendTemplate($to, $templateId, $params);
}
}

短信验证码

验证码服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
<?php

namespace App\Services\SMS;

use Illuminate\Support\Facades\Cache;

class VerificationCodeService
{
protected SMSManager $sms;
protected int $codeLength = 6;
protected int $expiryMinutes = 5;
protected int $maxAttempts = 5;

public function __construct(SMSManager $sms)
{
$this->sms = $sms;
}

public function send(string $phone, string $purpose = 'verification'): SMSResult
{
$this->checkRateLimit($phone, $purpose);

$code = $this->generateCode();

$result = $this->sms->sendTemplate($phone, 'verification_code', [
'code' => $code,
]);

if ($result->isSuccess()) {
$this->storeCode($phone, $code, $purpose);
}

return $result;
}

public function verify(string $phone, string $code, string $purpose = 'verification'): bool
{
$key = $this->getCacheKey($phone, $purpose);
$stored = Cache::get($key);

if (!$stored) {
return false;
}

$attempts = $stored['attempts'] ?? 0;

if ($attempts >= $this->maxAttempts) {
return false;
}

if ($stored['code'] !== $code) {
Cache::put($key, [
'code' => $stored['code'],
'attempts' => $attempts + 1,
], now()->addMinutes($this->expiryMinutes));

return false;
}

Cache::forget($key);

return true;
}

protected function generateCode(): string
{
return str_pad(random_int(0, pow(10, $this->codeLength) - 1), $this->codeLength, '0', STR_PAD_LEFT);
}

protected function storeCode(string $phone, string $code, string $purpose): void
{
$key = $this->getCacheKey($phone, $purpose);

Cache::put($key, [
'code' => $code,
'attempts' => 0,
], now()->addMinutes($this->expiryMinutes));
}

protected function getCacheKey(string $phone, string $purpose): string
{
return "sms:verification:{$purpose}:{$phone}";
}

protected function checkRateLimit(string $phone, string $purpose): void
{
$key = "sms:rate:{$purpose}:{$phone}";
$count = Cache::get($key, 0);

if ($count >= 5) {
throw new \RuntimeException('Too many verification code requests');
}

Cache::put($key, $count + 1, now()->addHour());
}
}

短信模板管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

namespace App\Services\SMS;

use App\Models\SMSTemplate;

class TemplateService
{
public function get(string $name, string $locale = 'en'): ?SMSTemplate
{
return SMSTemplate::where('name', $name)
->where('locale', $locale)
->where('is_active', true)
->first();
}

public function render(string $name, array $params, string $locale = 'en'): string
{
$template = $this->get($name, $locale);

if (!$template) {
throw new \RuntimeException("SMS template [{$name}] not found");
}

$content = $template->content;

foreach ($params as $key => $value) {
$content = str_replace("{{{$key}}}", $value, $content);
}

return $content;
}

public function create(string $name, string $content, string $locale = 'en'): SMSTemplate
{
return SMSTemplate::create([
'name' => $name,
'content' => $content,
'locale' => $locale,
'is_active' => true,
]);
}
}

短信日志

短信记录模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class SMSLog extends Model
{
protected $fillable = [
'phone',
'message',
'template_id',
'params',
'driver',
'message_id',
'status',
'error_code',
'error_message',
'sent_at',
];

protected $casts = [
'params' => 'array',
'sent_at' => 'datetime',
];

public function scopeSuccessful($query)
{
return $query->where('status', 'success');
}

public function scopeFailed($query)
{
return $query->where('status', 'failed');
}

public function scopeToday($query)
{
return $query->whereDate('created_at', today());
}
}

日志服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

namespace App\Services\SMS;

use App\Models\SMSLog;

class SMSLogger
{
public function log(
string $phone,
string $message,
SMSResult $result,
string $driver,
?string $templateId = null,
array $params = []
): SMSLog {
return SMSLog::create([
'phone' => $phone,
'message' => $message,
'template_id' => $templateId,
'params' => $params,
'driver' => $driver,
'message_id' => $result->messageId,
'status' => $result->isSuccess() ? 'success' : 'failed',
'error_code' => $result->errorCode,
'error_message' => $result->errorMessage,
'sent_at' => $result->isSuccess() ? now() : null,
]);
}
}

短信控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\SMS\SMSManager;
use App\Services\SMS\VerificationCodeService;
use Illuminate\Http\Request;

class SMSController extends Controller
{
public function __construct(
protected SMSManager $sms,
protected VerificationCodeService $verification
) {}

public function sendVerificationCode(Request $request)
{
$request->validate([
'phone' => 'required|string|regex:/^\+?[1-9]\d{1,14}$/',
'purpose' => 'nullable|string|in:registration,login,password_reset',
]);

$result = $this->verification->send(
$request->phone,
$request->purpose ?? 'verification'
);

if ($result->isSuccess()) {
return response()->json([
'message' => 'Verification code sent successfully',
]);
}

return response()->json([
'message' => 'Failed to send verification code',
'error' => $result->errorMessage,
], 400);
}

public function verifyCode(Request $request)
{
$request->validate([
'phone' => 'required|string',
'code' => 'required|string|size:6',
'purpose' => 'nullable|string',
]);

$valid = $this->verification->verify(
$request->phone,
$request->code,
$request->purpose ?? 'verification'
);

if ($valid) {
return response()->json([
'message' => 'Verification successful',
]);
}

return response()->json([
'message' => 'Invalid or expired verification code',
], 400);
}

public function send(Request $request)
{
$request->validate([
'phone' => 'required|string',
'message' => 'required|string|max:500',
]);

$result = $this->sms->send($request->phone, $request->message);

return response()->json([
'success' => $result->isSuccess(),
'message_id' => $result->messageId,
]);
}
}

短信命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace App\Console\Commands;

use App\Models\SMSLog;
use Illuminate\Console\Command;

class SMSCleanupCommand extends Command
{
protected $signature = 'sms:cleanup {--days=30 : Days to keep logs}';
protected $description = 'Cleanup old SMS logs';

public function handle(): int
{
$days = (int) $this->option('days');

$count = SMSLog::where('created_at', '<', now()->subDays($days))->delete();

$this->info("Deleted {$count} old SMS logs");

return self::SUCCESS;
}
}

总结

Laravel 13 的短信集成系统支持多种短信服务商,通过统一的接口和驱动模式,可以灵活切换短信渠道。结合验证码服务、模板管理和日志记录,可以构建完整的短信发送系统。