authy/php
PHP SDK for Twilio Authy two-factor authentication. Register and verify users, send and check one-time tokens, and manage phone-based 2FA through a simple, PSR-compatible client with Laravel-friendly integration.
Install and wire it up. The package is a thin, framework-agnostic cURL client — there is no service provider, facade, or config file, so Laravel integration is three lines you own.
# 4.x is the PHP 8 line; it is still a release candidate, so opt in explicitly
composer require authy/php:^4.0@RC
If you are still on PHP 7.4, stay on the 3.x line (composer require authy/php:^3.0) — 4.0 is the PHP 8 branch and there is no stable 4.x tag yet.
Add credentials to config/services.php (Twilio Authy API keys come in production and sandbox flavours; never ship one in JS):
'authy' => [
'key' => env('AUTHY_API_KEY'),
'url' => env('AUTHY_API_URL', 'https://api.authy.com'), // no trailing slash
],
Register it as a singleton in AppServiceProvider::register():
use Authy\AuthyApi;
$this->app->singleton(AuthyApi::class, fn () => new AuthyApi(
config('services.authy.key'),
config('services.authy.url'),
));
Where to look first. Everything lives in src/Authy/:
AuthyApi.php — the entire public surface (registerUser, verifyToken, requestSms, startPhoneVerification, checkPhoneVerification, approval-request/OneTouch methods, registerActivity). Read this file before writing anything; it's ~400 lines and the method signatures are the documentation. Signature order is unchanged in 4.0, but re-read it on upgrade — PHP 8 support usually arrives with type declarations.AuthyResponse.php — the object every call returns (ok(), id(), uuid(), message(), errors(), bodyvar()).First use case: phone verification at signup. Register the user, persist the returned Authy ID, then verify the code they typed.
$res = AuthyApi::class // resolved from container
->registerUser($user->email, $user->phone, 1); // 1 = country calling code
if (! $res->ok()) {
// 409-ish: user already exists in Authy — see Gotchas
}
$user->forceFill(['authy_id' => $res->id()])->save();
// later, at the prompt:
$check = $authyApi->verifyToken($user->authy_id, $request->input('code'));
if (! $check->ok()) { /* invalid code */ }
Wrap the client, don't sprinkle it. The raw API returns a generic AuthyResponse and signals failure with ok() === false — leaking that into controllers makes error handling inconsistent. A small service keeps your app testable, isolates the 4.0 RC churn, and gives you one place to swap in Twilio Verify later.
final class TwoFactorService
{
public function __construct(private AuthyApi $authy) {}
public function startVerification(User $user): void
{
if ($user->authy_id) {
$this->authy->requestSms($user->authy_id);
return;
}
$res = $this->authy->registerUser($user->email, $user->phone, $user->country_code);
if ($res->ok()) {
$user->forceFill(['authy_id' => $res->id()])->save();
return;
}
throw new TwoFactorException($res->errors()['message'] ?? 'Registration failed');
}
public function verify(User $user, string $token): bool
{
return $this->authy->verifyToken($user->authy_id, $token)->ok();
}
}
Persist authy_id — it is the only key you get. There is no "look up user by email/phone" endpoint that you should rely on, so the Authy ID must be a nullable column on users (indexed, unique). If you lose it you're reduced to re-registering and getting an "already exists" error with no ID in the body.
Use the phone-verification endpoints for pre-account checks. When you need to verify a number before a user row exists, use startPhoneVerification() / checkPhoneVerification() (see AuthyApi.php for the exact argument order) — these don't require an Authy ID and don't create a user. Only call registerUser() once you actually want Authy to own the second factor.
Queue anything that hits the wire. Every method is a blocking cURL call with default timeouts. SMS requests, OneTouch creation, and registerActivity() all belong in a queued job so a slow Authy response doesn't stall a login request.
dispatch(new SendTwoFactorCode($user))->onQueue('auth');
OneTouch (push approvals) is a poll-or-callback flow. createApprovalRequest(...) returns a uuid; store it on the session/audit row. Then either:
POST route, verify the signature header, and match on uuid. Keep the route outside auth middleware and exempt from CSRF.getApprovalRequest($uuid) from a short-lived polling endpoint with exponential backoff — status moves pending → approved|denied|expired. Pair it with verifyToken($authyId, $token, true) (the force flag) for the "no push, use a code" fallback.TOTP / Authy app codes. verifyToken() handles Authy-app tokens as well as SMS codes — there's no separate endpoint. Use registerActivity() to log context (IP, user agent) so the app shows meaningful approval prompts.
Config and environments. Point AUTHY_API_URL at http://sandbox-api.authy.com in local/staging and use the sandbox key. Sandbox accepts the fixed code 0000000 for test numbers, and never actually sends SMS — don't let a staging test make you think delivery is broken. Cache-config means key changes need php artisan config:clear.
4.0 is an RC, not a stable release — and the PHP 8 claim needs verifying. 4.0.0-rc2 is the first tag since March 2021 and its only release note is "Support for PHP 8". That's good news for the deprecation noise on modern runtimes, but: (a) "PHP 8" may mean 8.0 only — smoke-test verifyToken, registerUser, and AuthyResponse::errors() on 8.2/8.3 before rolling it out; (b) RC semver means the public surface can still shift, so pin the exact version ("authy/php": "4.0.0-rc2") rather than floating ^4.0; (c) check whether the upgrade added type declarations or changed bodyvar() return shapes, which would silently break untyped consumers of the response object. The Authy API itself remains on Twilio's retirement path, so keep the AuthyApi behind your own interface and treat this as a maintained-by-you dependency regardless of the RC.
ok() is boolean, errors() is a nested array — don't string-match messages. Twilio rewrites error copy. Branch on the HTTP-ish status/code in the error payload, and log bodyvar() on unexpected shapes.
409 on re-registration. registerUser() fails if the email/phone already exists in your Authy account. Guard with if ($user->authy_id) return; and make the registration path idempotent — this is the single most common production bug with this library.
No HTTP fake, no Guzzle. The client uses raw cURL through src/Authy/Util.php, so Http::fake() and Laravel's HTTP client events do nothing. To test, bind a Mockery mock of AuthyApi in the container, or (harder) use a curl shim. Design your service so the API object is injected, and most tests never touch it.
Add timeouts and a circuit breaker yourself. There's no timeout config — a hung Authy connection hangs your request. Subclass or wrap AuthyApi and set cURL options (CURLOPT_TIMEOUT, CURLOPT_CONNECTTIMEOUT) via Util's static options if available, otherwise wrap calls in a try/fail-fast path. Always degrade gracefully (fall back to backup codes) rather than 500 on a 2FA outage.
Never log tokens or the API key. The library happily puts request bodies into exception messages in places; scrub before feeding into Sentry/Laravel logs. Store authy_id but treat codes as single-use secrets.
Phone format matters. Pass the number without the country code and supply the country code separately (1 for US). E.164 strings like +15551234567 in the number field produce silent verification failures rather than errors. Normalise with a phone library once, at the model boundary.
Tokens are single-use and rate-limited. Authy invalidates a code after verifyToken() succeeds and after several failed attempts; requestSms() has per-user throttling. Cache a "last sent at" timestamp and refuse requests within ~30s, and show a distinct "too many attempts, use backup codes" state instead of a generic failure.
Keep a backup-code path. Authy will be unavailable or the user's phone will be lost. Generate hashed recovery codes at enrolment — this is the escape hatch that keeps 2FA from becoming a support-ticket generator, and it's your migration path when you leave this package.
How can I help you explore Laravel packages today?