overtrue/easy-sms
overtrue/easy-sms is a PHP SMS component with one unified API for dozens of Chinese cloud providers. Configure multiple gateways, use built-in or custom rotation and failover strategies, auto-select an available provider, and get consistent responses for logging and monitoring. Easily add or remove...
overtrue/easy-sms is framework-agnostic — it ships no Laravel service provider, no publishable config, and no facade. "Getting started" in a Laravel app is therefore three small steps, not one composer require.
1. Install (check your PHP version first). The current major line requires PHP >= 8.4. If your app is on 8.1–8.3 you must pin the older line (composer require overtrue/easy-sms:^1.0) or the install will fail resolution against laravel/framework.
composer require overtrue/easy-sms
2. Create the config and bind a singleton. The config array is the package's entire configuration surface; put it in config/easy-sms.php so it is env-driven and cacheable.
// config/easy-sms.php
return [
'timeout' => 5.0,
'default' => [
'strategy' => \Overtrue\EasySms\Strategies\OrderStrategy::class,
'gateways' => ['aliyun'],
],
'gateways' => [
'aliyun' => [
'access_key_id' => env('ALIYUN_SMS_KEY_ID'),
'access_key_secret' => env('ALIYUN_SMS_KEY_SECRET'),
'sign_name' => env('ALIYUN_SMS_SIGN_NAME'),
],
],
];
// app/Providers/AppServiceProvider.php (boot/register)
$this->app->singleton(\Overtrue\EasySms\EasySms::class, function ($app) {
return new \Overtrue\EasySms\EasySms($app['config']['easy-sms']);
});
3. First use case: an OTP code for login/verification.
$results = app(\Overtrue\EasySms\EasySms::class)->send($request->string('phone'), [
'template' => 'SMS_123456789',
'data' => ['code' => $code],
]);
Where to look first, in this order:
content / template+data that gateway consumes.src/Gateways/<YourGateway>Gateway.php — the README is incomplete for several providers; the gateway source is the authoritative list of required keys.src/Message.php and src/Exceptions/NoGatewayAvailableException.php — these define the two extension seams you will actually use.yl/easysms-notification-channel (linked in the README) instead of hand-rolling a channel.Message classes per scenario. This is the single biggest maintainability win: one class per business message, callable from controllers, jobs, and notifications.
class LoginCodeMessage extends \Overtrue\EasySms\Message
{
protected $strategy = \Overtrue\EasySms\Strategies\OrderStrategy::class;
protected $gateways = ['aliyun', 'yunpian']; // overrides default.gateways for this message
public function __construct(private string $code) {}
public function getTemplate(\Overtrue\EasySms\Contracts\GatewayInterface $gateway = null)
{
return $gateway?->getName() === 'yunpian' ? 'yunpian_tpl_01' : 'SMS_123456789';
}
public function getData(\Overtrue\EasySms\Contracts\GatewayInterface $gateway = null)
{
return ['code' => $this->code];
}
public function getContent(\Overtrue\EasySms\Contracts\GatewayInterface $gateway = null)
{
return "您的验证码为: {$this->code},5分钟内有效。"; // used by content-based gateways
}
}
Use closures when you don't want a class. The content / template / data keys all accept a closure receiving the gateway, which is the cheapest way to send one logical message across heterogeneous providers:
$easySms->send($phone, [
'template' => fn ($gateway) => $gateway->getName() === 'aliyun' ? 'SMS_1' : 'yunpian_2',
'data' => fn () => ['code' => $code],
]);
Wrap it in one application-level service. Don't call EasySms from controllers. A thin App\Services\SmsSender that normalizes the results array, logs per-gateway outcomes, and converts a total failure into a domain exception gives you one place to add retries, metrics, and masking.
public function send(string $phone, Message $message): array
{
try {
return $this->easySms->send($phone, $message);
} catch (NoGatewayAvailableException $e) {
Log::error('sms.all_failed', ['exceptions' => array_map(
fn ($ex) => $ex->getMessage(), $e->getExceptions()
)]);
throw new SmsFailedException('SMS could not be delivered', previous: $e);
}
}
Always send queued, never inline in a request. An SMS call is a 1–5 s external round-trip. Use a queued notification or dispatch(new SendSms(...))->onQueue('notifications'), then pair it with a RateLimiter / WithoutOverlapping middleware per phone number to avoid double-sends and provider quota bursts.
Per-call gateway override is the failover switch. The third argument to send() overrides default.gateways, which is useful for an emergency "use provider B only" toggle driven by an env flag rather than a deploy.
Custom gateways go through extend() and belong in the service provider, not in a controller — register once:
$easySms->extend('mygateway', fn ($config) => new MyGateway($config));
Testing. Never let the test suite hit a real provider. Register a fake gateway in the test case via extend() that records calls and returns a canned result, or assert on the PhoneNumber + message payload after calling getContent(). For feature tests, swap the singleton binding with a mock that returns a fixed results array.
International numbers. Use new PhoneNumber($number, 31) — plain strings default to 86-style handling and formatting differs per gateway.
errorlog is a trap in production. It's a real gateway that "sends" by writing to a file and returns a success status. If it lands in default.gateways by accident you get green logs and zero delivered SMS. Keep it to local/testing config only, or better, use a custom fake gateway.send() does not throw on partial failure. It returns an array keyed by gateway name; individual failures are buried in ['status' => 'failure', 'exception' => ...]. NoGatewayAvailableException is thrown only when every gateway fails. If you only try/catch, you will silently drop messages. Always inspect the results array and log per-gateway status.php artisan config:cache will break if your config contains closures. Any closure in config/easy-sms.php (a strategy callback, a custom gateway factory) is unserializable. Keep closures in code, keep the config file scalar-only.template + data; content-based gateways (yunpian, luosimao, huyi) need content. Passing only content to Aliyun fails at the provider with an opaque error. Either supply all three keys or branch per gateway with closures.data keys as template placeholders — ['code' => ...] vs a template expecting ${code}/code is provider-specific. A missing key typically returns a success-shaped HTTP response with a non-zero business code; check result, not just the exception.signature only when the content lacks 【】. If you hardcode a signature in content and set signature, you get two signatures in one message.OrderStrategy/RandomStrategy only choose which gateway to try, once. A transient 5xx from provider A does not roll over to B within the same gateway entry. Implement retry/backoff in your queued job ($tries, backoff()), not in the package.int phone column loses leading zeros and breaks E.164 handling; also validate/normalize before calling, since the package performs no validation.'timeout' => 5.0 applies to every gateway HTTP call; with 3 gateways a fully failing send can take 15 s. Keep queues and job timeouts aligned.result payload contains the recipient number and often the code/message body. Mask numbers and never log OTP values — log gateway name, status, and a hash of the number instead.PhoneNumber, config key names, per-gateway required params). When something doesn't work, trust src/Gateways/<name>Gateway.php over the README snippet.Overtrue\EasySms\Contracts\StrategyInterface::apply(array $gateways): array to get cost-aware or health-check-driven routing, then set it as default.strategy or on a Message's $strategy property.How can I help you explore Laravel packages today?