recurly/recurly-client
Official PHP client for Recurly's V3 API. Install via Composer (recurly/recurly-client), create a client with your API key, and access billing, subscription, and account operations in one place. Supports EU region and PSR-3 logging.
Install and confirm the major version. composer require recurly/recurly-client:^4. This is the V3 API client only — if you inherit a codebase calling Recurly_Client, Recurly_Account, or anything else unprefixed, that's the V2 library living in the v2 branch and it is a different package entirely. Don't mix them in one app.
Get credentials, then pin the region. Pull an API key from the Recurly dashboard (Integrations → API Credentials) and store it in .env only. If your Recurly account is hosted in Europe you must pass ['region' => 'eu'] at construction — there is no runtime auto-detection and a wrong region looks like an auth failure rather than a config error.
Read in this order: the Developer Portal for endpoint/field semantics (it applies 1:1 to this client), then the Github Pages API reference for exact PHP method signatures and return types, then CHANGELOG only to see whether a field you depend on moved. The README is thin; the generated docblocks on \Recurly\Client are the real documentation.
Wire it once in a service provider. The client is stateless and safe to share, so bind a singleton rather than new-ing it per call:
// config/recurly.php
return [
'api_key' => env('RECURLY_API_KEY'),
'options' => array_filter([
'region' => env('RECURLY_REGION'), // omit unless 'eu'
]),
];
// AppServiceProvider::register()
$this->app->singleton(\Recurly\Client::class, fn () => new \Recurly\Client(
config('recurly.api_key'),
app('log'), // PSR-3; suppresses the default stdout logger
config('recurly.options'),
));
First use case — the read path. Treat Recurly as a system of record you query, not one you mirror casually:
$plan = $client->getPlan('code-gold'); // note the `code-` prefix for code lookups
$account = $client->getAccount('code-douglas');
foreach ($client->listAccounts(['params' => ['past_due' => true, 'limit' => 200]]) as $account) {
echo $account->getCode(), PHP_EOL;
}
Identifiers are the single most common source of confusion early on: V3 accepts either the internal UUID or a code--prefixed business key, and NotFound usually means you forgot, not that the record is missing.
Wrap the client behind a narrow repository interface. \Recurly\Client is a generated god-object with ~200 methods and typed resource classes with getters only. Nobody wants $client->listInvoices(...) sprinkled through controllers. Define app/Recurly/RecurlyGateway.php with the handful of operations you actually use, return your own DTOs or Eloquent models, and keep \Recurly\ imports confined to that directory. This is also the only realistic testing seam — see below.
Map resources to local models, don't cache raw objects. Resource objects are read-only snapshots with an attached \Recurly\Response. Persist uuid, code, state, and the fields your UI needs. Then keep them fresh from webhooks.
Webhook ingestion is the integration backbone. The client does not verify signatures and does not parse webhooks, so hand-roll it: capture the raw body in a route (no model binding, no middleware that rewrites the payload), verify against the Recurly-Signature header, persist the notification id for idempotency, then dispatch a queued job that re-fetches the resource through the client before writing. Re-fetching matters because webhooks can arrive before the API read path reflects them and the client has no eventual-consistency handling.
Pagination is lazy — design for it. list* returns a \Recurly\Pager immediately; HTTP fires on iteration. For syncs, wrap the loop in a queued job with limit => 200, checkpoint on the last seen uuid, and treat getCount() (a HEAD request) as an occasional dashboard call, not a loop condition. getFirst() is the efficient "newest/oldest one" primitive — invert order to get the other end instead of iterating:
$latestInvoice = $client->listAccountInvoices($accountId, [
'params' => ['order' => 'desc', 'limit' => 1],
])->getFirst();
Writes take arrays, not resource objects. Everything on the write side is an associative array validated server-side, so build them from validated FormRequests / DTOs and let your own layer own the required-field rules. update* is a partial merge; sending a nested array replaces the collection, it does not append.
Centralise error translation. All exceptions extend \Recurly\RecurlyError, with \Recurly\Errors\Validation and \Recurly\Errors\NotFound as the two you'll actually branch on. Map them once, in your exception handler or gateway:
try {
$client->deactivateAccount($accountId);
} catch (\Recurly\Errors\Validation $e) {
throw new \App\Exceptions\BillingValidationFailed($e->getParams()); // field-level reasons
} catch (\Recurly\Errors\NotFound $e) {
throw new \App\Exceptions\BillingRecordMissing(previous: $e);
} catch (\Recurly\RecurlyError $e) {
report($e); // log request id, then rethrow for 5xx
}
Use the response metadata for observability. Every resource exposes getResponse() with getRequestId() and getRateLimitRemaining(), and this works on the Empty resource returned by deletes. Log the request id (never the whole body) on every failed call — Recurly support will ask for it — and read the rate-limit header in your gateway to trigger a backoff before you get throttled.
Testing: fake your gateway, not the client. There is no HTTP fake for this package. Because everything funnels through your interface, bind a fake implementation in tests and assert your domain behaviour; reserve one or two real-client integration tests behind a sandbox key for contract checks.
The constructor's second argument is overloaded. The README shows both new Client($key, $logger) and new Client($key, ['region' => 'eu']). Passing a logger and options is where people slip — check the actual signature of your installed version and pass options as the third argument, or you'll silently lose your logger or your region.
The default logger writes to stdouts. \Recurly\Logger prints to php://stdout at WARNING. In a web request that means log lines in HTTP responses, and in Laravel it bypasses your channels. Always inject app('log') or a dedicated channel. And never set the level to DEBUG in production — the client logs request bodies, which contain PII and card-adjacent data.
README code samples contain an actual syntax typo. The filtering example reads array('params' = array( — it's =>. Worse, the same README shows flat params (listAccounts(['past_due' => true])) in the getCount() example and nested (['params' => [...]]) elsewhere. Trust the docblock on your installed version, and if filtering appears to be ignored, try the other shape before assuming the endpoint is broken.
list* never throws where you think. Errors from a pager surface during foreach, not at the call site. A try/catch around $client->listAccounts() catches nothing. Wrap the iteration.
Unpaginated loops will rate-limit you. Each pagination page is one HTTP call. A nightly job that iterates all accounts one page at a time can also hit timeouts in a queue worker — set limit => 200, chunk across jobs, and add a small sleep based on getRateLimitRemaining().
getCount() costs a real request. It issues HEAD and parses Recurly-Total-Records. Don't call it inside a render loop or a table header that refreshes; cache it. Sorting params are ignored by it, filtering params are honoured.
Releases are machine-generated, so semver is coarse. The changelog is almost entirely "Generated Latest Changes for v2021-02-25" from the OpenAPI spec — dozens of minor versions a year. Pin the major (^4) and let Composer float minors, but read the diff when a new field appears in a resource you serialize: new nullable fields can change your JSON contracts. Breaking changes only land on majors, which do happen.
No retries, no idempotency, no circuit breaker. Build them in your gateway: retry on connection errors and 5xx with jittered backoff, fail fast on 4xx, and consider an idempotency key on POSTs so a retried createSubscription doesn't double-charge.
Never log or dump full requests/responses. The README warns explicitly, and the client will happily give you getRequest()->getBodyAsJson(). If you need debug output, whitelist specific keys.
config:cache bakes your API key into the compiled config. That's fine operationally but means key rotation requires a redeploy/recache — and means the key must never be read from a file that ends up in a public artifact.
Extension points are effectively nil. The library is generated, not extensible: no middleware, no events, no pluggable transport. Anything cross-cutting (retry, metrics, caching, PII scrubbing) must live in your wrapper. Design the wrapper first; retrofitting one after 40 controllers import \Recurly\Client is the expensive path.
How can I help you explore Laravel packages today?