tencentcloud/sms
Send SMS messages through Tencent Cloud from your Laravel or Lumen app. This package wraps the Tencent Cloud SMS SDK in a simple, configurable service, so you can dispatch verification codes and notifications with clean, framework-friendly code.
tencentcloud/sms is an auto-generated subtree split of the tencentcloud-sdk-php monorepo — almost everything you need lives in tencentcloud/common, which is pulled in automatically. Treat it as a raw transport SDK, not a Laravel package: no service provider, no facade, no config file, no auto-discovery.
composer require tencentcloud/sms
# pulls tencentcloud/common as a dependency
Everything you touch lives in one namespace that encodes the API version:
use TencentCloud\Sms\V20210111\SmsClient;
use TencentCloud\Sms\V20210111\Models\SendSmsRequest;
Where to look first
vendor/tencentcloud/sms/src/TencentCloud/Sms/V20210111/Models/ — every request/response field is a setX() method here. This is your real documentation.vendor/tencentcloud/common/src/TencentCloud/Common/ — Credential, ClientProfile, HttpProfile, TencentCloudSDKException.cloud.tencent.com/document/api/382/ for error codes and template/sign rules — the repo itself has none.First use case: an OTP send. Add config first:
// config/services.php
'tencent_sms' => [
'secret_id' => env('TENCENT_SMS_SECRET_ID'),
'secret_key' => env('TENCENT_SMS_SECRET_KEY'),
'sdk_app_id' => env('TENCENT_SMS_SDK_APP_ID'),
'sign_name' => env('TENCENT_SMS_SIGN_NAME'),
'region' => env('TENCENT_SMS_REGION', 'ap-guangzhou'),
'endpoint' => 'sms.tencentcloudapi.com',
],
Then a thin wrapper — this is the boundary you will mock, log, and retry at:
namespace App\Services;
use TencentCloud\Common\Credential;
use TencentCloud\Common\Profile\ClientProfile;
use TencentCloud\Common\Profile\HttpProfile;
use TencentCloud\Sms\V20210111\SmsClient;
use TencentCloud\Sms\V20210111\Models\SendSmsRequest;
class TencentSms
{
private SmsClient $client;
public function __construct(array $cfg)
{
$http = new HttpProfile();
$http->setEndpoint($cfg['endpoint']);
$http->setReqTimeout(10); // seconds; default is much longer
$profile = new ClientProfile();
$profile->setHttpProfile($http);
// $profile->setSignMethod('TC3-HMAC-SHA256'); // already the default
$this->client = new SmsClient(
new Credential($cfg['secret_id'], $cfg['secret_key']),
$cfg['region'],
$profile
);
}
/** @return array<int, array{Code:string, Message:string, PhoneNumber:string, SerialNo:string}> */
public function send(string $templateId, array $params, array $phoneNumbers, ?string $context = null): array
{
$req = new SendSmsRequest();
$req->setSmsSdkAppId(config('services.tencent_sms.sdk_app_id'));
$req->setSignName(config('services.tencent_sms.sign_name'));
$req->setTemplateId($templateId);
$req->setTemplateParamSet(array_map('strval', $params)); // strings only
$req->setPhoneNumberSet($phoneNumbers);
if ($context) {
$req->setSessionContext($context);
}
$resp = $this->client->SendSms($req);
return json_decode($resp->toJsonString(), true)['SendStatusSet'] ?? [];
}
}
// AppServiceProvider::register()
$this->app->singleton(TencentSms::class, fn () => new TencentSms(config('services.tencent_sms')));
Call it: app(TencentSms::class)->send('1234567', [$code], ['8613800138000'], 'otp:'.$userId).
1. Interface first, vendor second. The single highest-value thing you can do with this package is never let it leak past one class. Define your own contract and bind Tencent as one driver:
interface SmsSender { public function send(string $template, array $params, string $phone): void; }
$this->app->bind(SmsSender::class, match (config('sms.driver')) {
'tencent' => fn () => new TencentSmsDriver(app(TencentSms::class), config('sms.templates')),
default => fn () => new LogSmsDriver(),
});
Now SmsSender::class can be faked in tests and swapped in local dev with zero SDK mocking pain.
2. Template IDs belong in config, keyed by intent, per environment. Tencent template IDs and sign names differ between approval environments and are effectively immutable once approved.
// config/sms.php
'templates' => [
'otp' => env('TENCENT_SMS_TPL_OTP'), // params: [code, expire_minutes]
'order' => env('TENCENT_SMS_TPL_ORDER'), // params: [order_no, amount]
'return' => env('TENCENT_SMS_TPL_RETURN'),
],
Callers ask for $sms->send('otp', [$code, '5'], $phone) — they never see a numeric template ID.
3. Normalise phone numbers at the edge. Tencent wants E.164 without the +: 8613800138000, 85298765432, 14155552671. Do this in a single helper and validate before hitting the API — a malformed number is a billed, failed request.
function tencent_phone(string $e164): string {
return ltrim(preg_replace('/[^0-9]/', '', $e164), '+');
}
4. OTP flow: the SDK is the last step, not the flow. Rate limit and store the code before sending, so a failed send doesn't leave a valid-but-undelivered code, and a slow API call doesn't hold a DB transaction:
$key = "otp:{$phone}";
RateLimiter::hit("sms:{$phone}", 3600);
if (RateLimiter::tooManyAttempts("sms:{$phone}", 5)) {
throw ValidationException::withMessages(['phone' => 'Too many attempts.']);
}
$code = (string) random_int(100000, 999999);
Cache::put($key, Hash::make($code), now()->addMinutes(5));
$statuses = app(TencentSms::class)->send(
'otp', [$code, '5'], [tencent_phone($phone)], 'otp:'.$phone
);
if (($statuses[0]['Code'] ?? null) !== 'Ok') {
Log::warning('sms.failed', ['phone' => mask($phone), 'status' => $statuses[0] ?? null]);
Cache::forget($key);
throw new \RuntimeException('SMS delivery failed');
}
5. Queue anything that isn't a login OTP. SendSms is a blocking HTTP call (~100–500 ms). Marketing/notification SMS should go through a job with $tries = 3, backoff = [5, 30, 120], and a failed() handler that writes a row to your sms_logs table. For OTP you often want it inline — but still wrap it in a try/catch with a short timeout (5–10 s) so a slow Tencent endpoint doesn't stall your login request.
6. Build a Notification channel for anything user-facing:
class SmsChannel
{
public function send($notifiable, Notification $notification): void
{
$msg = $notification->toSms($notifiable);
app(SmsSender::class)->send($msg->template, $msg->params, $notifiable->phone);
}
}
Route via via() returning [SmsChannel::class, 'mail'] — multi-channel fallback for free.
7. Pass SessionContext with something traceable ("otp:{$userId}:{$requestId}"). It round-trips into SendStatusSet and into the console's send records, giving you a join key between your logs and Tencent's when a user says "I never got it."
8. Batch deliberately. PhoneNumberSet accepts many numbers, but one bad entry doesn't fail the call — you get a per-number result array. Batch in chunks of ~50 (well under the 200 cap), inspect every status, and split successes from failures into your delivery log.
9. Reporting uses the same client: PullSmsSendStatus / PullSmsSendStatusByPhoneNumber for delivery receipts, SendStatusStatistics for daily volume + failure rate. Put these in a scheduled command and feed a dashboard rather than querying per-request.
10. Testing. Mock your own interface or TencentSms — do not try to fake the SDK's HTTP layer. Http::fake() does nothing here; the common package uses its own Guzzle client. For a smoke test against the real API, use a dedicated TENCENT_SMS_SDK_APP_ID with a test template and one real phone.
tencentcloud/tencentcloud-sdk-php instead to avoid two copies of tencentcloud/common at different versions.TencentCloud\Sms\V20210111 — when Tencent ships a new API version, your imports change. This is why you pin an exact version ("tencentcloud/sms": "3.x.y") and commit composer.lock. composer update on an auto-generated SDK will silently change request/response classes.SendSms returning 200 does not mean it sent. The top-level response is a batch envelope; each SendStatusSet entry has its own Code / Message. Only Code === 'Ok' counts. The classic bug is $response->getSendStatusSet() truthy → treat as delivered.TencentCloudSDKException (getErrorCode(), getRequestId(), getMessage()). You need both a try/catch and per-entry status checking.TemplateParamSet must be strings, in template order. {1}, {2} in the approved template map to array index 0, 1. An int 123456 may serialize inconsistently — always array_map('strval', …). Never include newlines, URLs, or marketing language in a verification-code template; it violates the approved content and gets rejected silently at send time.SmsSdkAppId. Plan a day of lead time per environment. Error codes to watch: FailedOperation.TemplateIncorrectOrUnapproved, FailedOperation.SignatureIncorrectOrUnapproved.ReqTimeout to 5–10 s. The SDK default is generous; a hung socket on your checkout page is worse than a failed send. The SDK does not retry. If you add retries, do it at the job level, and only with your own dedupe guard — Tencent has no idempotency key, so a retry after a timeout genuinely double-sends (and double-bills).LogSmsDriver and a sms.driver=fake env flag early; use it for all local and CI runs.SmsClient once as a singleton. Building a new credential + Guzzle stack per message inside a loop is measurably expensive.Http::fake() and Laravel's HTTP events do not apply. No middleware, no Http::retry, no proxy settings from config('app'). If you need a corporate proxy, set it on the SDK's HttpProfile or via HTTP_PROXY/HTTPS_PROXY env, which Guzzle reads.sms.tencentcloudapi.com — not the region-scoped endpoints some other Tencent products use.Credential supports setToken() from STS) over long-lived secrets. Never log the secret key; never commit .env variations.138****8000) plus SerialNo, not the full number. Keep the send record — regulators and support both want it — but store it in your own table with a retention policy, not just in laravel.log.LimitExceeded.PhoneNumberDailyLimit (same number, per day), LimitExceeded.PhoneNumberOneHourLimit, and app-level QPS caps. Your rate limiter should sit in front of the SDK so you get a clean 429 to the user instead of an exception from Tencent.toJsonString() and json_decode(..., true), or serialize(). There's no ArrayAccess, no Laravel data_get() ergonomics.ClientProfile/HttpProfile — timeout, protocol, proxy, sign method. If you need more (tracing, metrics), wrap the client; there is no middleware hook.How can I help you explore Laravel packages today?