nexmo/client
Nexmo Client is the legacy PHP SDK for sending SMS, making voice calls, and managing Nexmo/Vonage APIs. It provides simple authentication and request helpers for PHP apps, though new projects should use the maintained Vonage client.
nexmo/client is a thin metapackage: it requires nexmo/client-core (where every Nexmo\* class actually lives) and a concrete PSR-18/PSR-7 HTTP client so you don't have to wire one yourself. Practically, you install one thing and get Nexmo\Client.
composer require nexmo/client:^2.4
Since 2.4.0 this requires PHP >= 7.2 and Guzzle ^7.0 — check your app's Guzzle constraint before you upgrade an existing install, because Laravel 6/7 skeletons pinned Guzzle 6 and Composer will refuse to resolve until you bump it.
Minimum viable setup — read credentials from config, never hardcode:
use Nexmo\Client;
use Nexmo\Client\Credentials\Basic;
$client = new Client(new Basic(
config('services.nexmo.key'),
config('services.nexmo.secret')
));
// Lowest-friction smoke test: outbound SMS
$message = $client->sms()->send(
new \Nexmo\Message\Text('447700900000', 'Acme', 'Hello from Laravel')
);
$messageId = $message->getMessageId(); // persist this
$message->getStatus(); // '0' == accepted
Where to look first:
vendor/nexmo/client-core/src/Client.php — the constructor signature and the $options array (http_client, base_api_url, logger) tell you almost everything about integration.sms(), messages(), verify(), account(), numbers(), calls()/voice(). Each namespace directory has a Client.php listing the exact method signatures for your pinned version.vendor/nexmo/client-core/src/Client/Exception/ — four exception types define your entire error-handling strategy.First real use case: transactional SMS (2FA code or notification) plus the delivery-receipt webhook. That exercises credentials, send, exception handling, and signature verification in one flow — the same surface area you'll reuse for everything else.
Bind it as a singleton in a service provider. The client is stateless and HTTP-connection-heavy; constructing it per call is waste, and in a queued job loop it will dominate your runtime. Because 2.4.0 pins Guzzle 7, the discovered adapter is php-http/guzzle7-adapter (or Guzzle 7 directly, which is PSR-18 compatible) — if you were on guzzle6-adapter, that swap is part of the upgrade.
// AppServiceProvider::register()
$this->app->singleton(Client::class, fn () => new Client(
new Credentials\Container(
new Credentials\Basic(
config('services.nexmo.key'),
config('services.nexmo.secret')
),
new Credentials\Signature(config('services.nexmo.signature_secret')),
),
['http_client' => app(\Psr\Http\Client\ClientInterface::class)]
));
The Container matters: Basic (key/secret) covers SMS/Verify/Numbers; Signature (API secret) is needed only for SMS signature-based auth; Keypair (application ID + private key) is needed for Voice and the newer Messages API. Holding all three in one container lets $client->sms(), $client->verify() and $client->calls() each select what they need.
Wrap sending in a small domain service, not controllers. Controllers should not know about Nexmo\Message\Text.
class SmsSender
{
public function __construct(private Client $client) {}
public function send(string $toE164, string $text, string $from): string
{
try {
$msg = $this->client->sms()->send(new Text($toE164, $from, $text));
Log::info('sms.sent', ['id' => $msg->getMessageId(), 'to' => $toE164]);
return $msg->getMessageId();
} catch (Exception\Request $e) {
// 4xx: bad number, unprovisioned sender, insufficient credit
report($e);
throw new SmsFailed($e->getMessage(), previous: $e);
} catch (Exception\Throttle $e) {
// 429 — retry with backoff, this is recoverable
throw new SmsThrottled($e->getRateLimit(), previous: $e);
}
}
}
Queue the send, always. Wrap SmsSender::send() in a job with $tries = 3, backoff() => [1, 5, 30], and retryUntil — but only retry on Throttle and Server, never on Request. Pass scalars (phone number, text, correlation ID) into the job; never serialize Nexmo\Client or its credentials into the payload.
Idempotency and receipts. The send response gives you message-id but not delivery status — that arrives later as an inbound webhook (status, err-code, message-timestamp). Persist message_id, to, status at send time, keyed uniquely on message_id, then upsert on webhook receipt. Webhooks can be delivered more than once; make the handler idempotent.
Verify (2FA) as a two-route flow. verify()->start() returns a request ID; store it on the user/session row, then verify()->check($requestId, $code) on the confirm route. Don't keep it in the session only — users switch devices mid-flow. Wrap check() separately because it throws a distinct exception on an incorrect code, and you'll want to count attempts yourself before letting Nexmo rate-limit you.
Webhook signature verification. For inbound SMS, verify the sig parameter before trusting the payload:
$verified = $client->verifySignature($request->input('sig'), $request->except('sig'));
abort_unless($verified, 403);
Return 200 fast, dispatch a job for processing, and never call back out to Nexmo inside the webhook handler.
Testing. Inject a mock PSR-18 client instead of hitting the network. php-http/mock-client (Http\Mock\Client) drops straight into the http_client option and lets you assert on the URI and body — note you need a release that's Guzzle-7/PSR-18 aware, so pin it alongside the SDK:
$http = new Http\Mock\Client();
$http->addResponse(new GuzzleHttp\Psr7\Response(200, [], json_encode([...])));
$client = new Client(new Basic('k', 's'), ['http_client' => $http]);
For Laravel feature tests, bind that mocked client into the container in setUp() so nothing real is ever sent from CI.
nexmo/client stopped at 2020-09-10 and was superseded by vonage/client, where Nexmo\Client became Vonage\Client (a mostly mechanical namespace swap, with the sms()/messages() split tidied up). 2.4.0 is the last maintenance bump on that dead branch — a dependency refresh, not new work. If you are starting fresh, install vonage/client; it has current Guzzle support, PHP 8.x fixes, and security patches. Treat this as the thing you're migrating away from, and budget for it.^2.0 will silently move you there. If you're on PHP 7.1 or Guzzle 6, resolution fails or you're stuck on 2.3.x. Pin explicitly and commit composer.lock.client-core whose method signatures differ (sms() vs messages(), calls() vs voice(), Text constructor argument order). Pin ^2.4 and commit composer.lock, then read the sub-client's Client.php for your exact version rather than the online docs.composer require nexmo/client-core alongside it. You'll get two copies or a version fight. Let the metapackage resolve it.['http_client' => ...] explicitly — and after the Guzzle 7 jump, confirm the bound client isn't still the 6-era adapter.+ — 447700900000, not +44 7700 900000. This trips up every first implementation. Validate and normalize before it reaches the SDK.from must be a sender ID provisioned on your account (or an approved alphanumeric string). An unprovisioned from throws Exception\Request, not a validation error on your side.Nexmo\Client\Exception\Request (4xx, has getEntity() with the raw API response — your debugging goldmine), Exception\Server (5xx, retryable), Exception\Throttle (429, retry with backoff), Exception\Validation (local pre-flight failures). A broad catch (\Exception) will swallow a bad Throttle and turn a retryable blip into a lost message.verifySignature() is SMS-specific and MD5-based. It validates the legacy sig query parameter. It does not validate the Authorization: Bearer <JWT> header used by the newer Messages API/Voice webhooks — those need a JWT check against your application's public key. Using the wrong verifier for the wrong product is a classic security hole.new Client($creds, ['logger' => $logger]) logs request/response bodies, which is invaluable — but the signer and credentials can leak into the output. Gate on app()->isLocal() and redact before shipping to a log aggregator.RequestException no longer exposes the response the same way in some paths) and dropped PHP < 7.2.5. If you have your own Guzzle middleware or HandlerStack customization layered under the SDK, re-test it after the 2.4.0 upgrade — the exception surface you intercept has shifted.Bus::batch() with a throttle, or lean on Exception\Throttle plus exponential backoff rather than a hard-coded sleep().verify()->start() — one per user action, and enforce a client-side cooldown so a user mashing "resend" doesn't burn credit.base_api_url is the escape hatch. Set it to a local fake for integration tests or for proxying through a corporate egress gateway — but remember it changes for the Voice/Numbers hosts too, which are separate base URLs internally.Nexmo\Client. The SDK does not expose a middleware stack of its own, and subclassing its HTTP handling breaks across patch releases.Nexmo\Client and its credential objects are not safe to serialize into a queued job payload — they hold a live HTTP client. Pass IDs and re-resolve from the container in handle().How can I help you explore Laravel packages today?