xeroapi/xero-php-oauth2
A PHP SDK for the Xero API using OAuth 2.0. It provides ready-made classes and models for accounting, payroll, and other Xero endpoints, handling authentication, token refresh, and requests so you can integrate Xero into Laravel or any PHP app quickly.
composer require xeroapi/xero-php-oauth2 — most Laravel apps pair it with the xeroapi/xero-laravel wrapper, which supplies config publishing, a token model, and a ready-made StorageInterface. Install the SDK alone only if you already own the token lifecycle.https://app.test/xero/callback. Put them in config/services.php (or config/xero.php if using the wrapper), never in code..env with XERO_CLIENT_ID, XERO_CLIENT_SECRET, XERO_REDIRECT_URI, and XERO_SCOPES — start with offline_access accounting.transactions accounting.contacts and add scopes as you need them. Scopes are baked into the refresh token, so widening them later forces every tenant to re-consent.xero-tenant-id returned by /connections.$config = \XeroAPI\XeroPHP\Configuration::getDefaultConfiguration()
->setClientId(config('services.xero.client_id'))
->setClientSecret(config('services.xero.client_secret'))
->setAccessToken($token->access_token);
$api = new \XeroAPI\XeroPHP\Api\AccountingApi(new \GuzzleHttp\Client(), $config);
$invoices = $api->getInvoices($tenantId, new \DateTime('2024-01-01'), null, ['AUTHORISED'], null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 1);
Look first at the generated Api/*Api.php method signatures (they are long positional parameter lists) and the Models/Accounting classes — that is the surface you will actually code against.
Wrap the generated APIs. Never call AccountingApi from a controller or job directly. Create thin repositories (XeroInvoiceRepository, XeroContactRepository) that take the tenant ID and return your own DTOs or Eloquent models. The 16.2.0 release regenerates models from OAS 19.0.0 — every upstream spec bump can shift a signature or add a nullable field, and a facade layer is the only thing that stops that from rippling across your app.
Token handling is your job. Implement XeroAPI\XeroPHP\StorageInterface against an Eloquent xero_tokens table (columns: tenant_id, access_token, refresh_token, expires_at, scopes). Before every request, refresh if expires_at is within ~2 minutes. Tokens live 30 minutes and refresh tokens rotate, so wrap refresh in Cache::lock("xero-refresh:{$tenantId}") — two concurrent jobs refreshing with the same refresh token will invalidate each other and force re-consent.
Multi-tenant fan-out. One Xero "connection" equals one organisation. Store connections per user, then iterate:
foreach ($user->xeroConnections as $connection) {
dispatch(new SyncXeroInvoices($connection->tenant_id));
}
Never share a Configuration instance across tenants in the same request — the tenant ID is passed per call but the access token is not.
Writes belong in queued jobs. Set $tries = 5, backoff() returning [10, 60, 300], and treat HTTP 429 specially (see gotchas). Use ShouldBeUnique keyed on the Xero document ID for creates so a retried job doesn't double-post an invoice.
Read efficiently with delta sync. For any list endpoint take advantage of the If-Modified-Since header and where date filtering rather than pulling everything nightly:
$config->setDefaultHeaders(['If-Modified-Since' => $lastSync->toRfc7231String()]);
Persist last_successful_sync_at per tenant/entity and resume from there. Paginate with the page parameter (most accounting endpoints cap at 100 records per page); loop until a page returns fewer than the max.
Prefer summarised endpoints. getReportBalanceSheet, getReportProfitAndLoss, and the batch payment summaries are one call versus dozens — worth checking the API reference before hand-rolling aggregation.
Webhooks over polling. Register a webhook via /webhooks, verify the x-xero-signature HMAC with your webhook key, return 200 immediately, and push processing onto the queue. Webhooks only tell you that something changed plus the resource ID — you still fetch the entity. Combine with the delta sync as a safety net for missed deliveries.
Integration testing. Point at https://api.xero.com/api.xro/2.0 for sandbox orgs by keeping base paths in config; use Laravel's Http::fake() against captured payloads rather than hitting Xero in CI.
toArray() output. Diff the Models/ and Api/ directories between 16.1.0 and 16.2.0 before merging, and run your integration suite against a sandbox tenant — your JSON fixtures may need regenerating."xeroapi/xero-php-oauth2": "16.2.*" rather than ^16.0, and read the CHANGELOG. Major bumps have historically renamed/removed model setters.Retry-After header and sleep for it; don't use a fixed backoff. On HTTP 503 back off harder — Xero throttles aggressively during peak.ApiException carries the real message. Always catch (\XeroAPI\XeroPHP\ApiException $e) and log $e->getResponseBody() — a bare "400 Bad Request" from Guzzle tells you nothing, but the body contains ValidationErrors with Message and ElementPath pointing at the offending field.DateTimeInterface or Y-m-d strings, not Carbon's default serialisation. Carbon::parse($x)->setTime(0,0) before passing to avoid timezone drift shifting an invoice into the wrong day.TaxType on an ACCREC are the two most common cause of 400s. Compute LineAmount yourself and set LineAmountTypes explicitly (Exclusive/Inclusive/NoTax) — the default is not always what you expect.[] for empty collections and null for absent scalars. Guard with ?? null and count() checks rather than assuming presence.vendor/xeroapi/. If you need to patch generated behaviour, subclass the API/model in your own namespace and bind it, otherwise composer update will overwrite you.Configuration::setDefaultHeaders(), a custom Guzzle handler stack (for logging and metrics), and the StorageInterface are the three sanctioned hooks. Logging middleware on the Guzzle client is the fastest way to diagnose request/response issues in production without redeploying.How can I help you explore Laravel packages today?