langleyfoxall/xero-laravel
Xero Laravel lets developers access the Xero accounting system with Eloquent-like syntax, supporting the OAuth 2.0 flow. Install via Composer, publish the config, add your client credentials and redirect URI, then query Xero organisations and data as if they were Laravel models.
The package is a thin, opinionated read layer over xero-php-oauth2 (the official Xero PHP SDK). It gives you $xero->contacts()->where(...)->get() instead of hand-rolling HTTP calls and token plumbing. Get it wired up in this order — skip a step and you'll spend an afternoon chasing 401s.
1. Install and publish config
composer require langleyfoxall/xero-laravel
php artisan vendor:publish --provider="LangleyFoxall\XeroLaravel\Providers\XeroLaravelServiceProvider"
This drops config/xero-laravel-lf.php. Note the -lf suffix — if you're grepping for xero.php you'll miss it.
2. Create the Xero app and populate .env
Create an app at developer.xero.com/myapps (Web app type), then:
XERO_CLIENT_ID=...
XERO_CLIENT_SECRET=...
XERO_REDIRECT_URI=https://example.com/xero/callback
The redirect URI must match the Xero app config character for character, trailing slash included. This is the single most common first-hour failure.
3. Set your scopes before you go live. The published default scopes are deliberately minimal. Add what you actually need now, because adding them later forces every user to re-consent:
offline_access — required to receive a refresh token at all. Without it the whole flow works and then dies silently at minute 31.accounting.transactions for invoices/bank transactionsaccounting.contacts / accounting.contacts.readaccounting.settings.read for accounts, tax rates, tracking categories (already in defaults)4. Walk the OAuth 2.0 flow. The README controller is genuinely the right template — redirect, callback, tenant select, persist, refresh. Two routes, one controller, done. Store the token JSON and tenantId against your user (TEXT + encrypted cast).
5. First use case to validate the wiring: after a successful callback, hit $xero->organisations and $xero->contacts()->first() in Tinker. organisations is the cheapest sanity check that your token, tenant ID, and scopes are all aligned. Once that returns a collection, everything else in the relationship list works the same way.
Where to read the source: OAuth2.php for the auth surface, XeroApp.php for how relationship names resolve, and the relationship list in the README as your actual API index — it's the most useful page in the docs.
Never construct XeroApp in a controller. You'll repeat the token-loading boilerplate twenty times and forget the refresh check in half of them. Bind a resolver:
// AppServiceProvider
$this->app->bind(XeroApp::class, function ($app) {
$user = auth()->user();
return new XeroApp(
new AccessToken(json_decode($user->xero_access_token, true)),
$user->xero_tenant_id
);
});
Then inject XeroApp into controllers, jobs, and services. Everything downstream is testable and consistent.
Refresh in middleware, not in every method. The README's step 5 is correct but belongs in a EnsureXeroTokenIsFresh middleware (or a job base class for queued work) so it runs exactly once per request before any Xero call:
if ($token->hasExpired()) {
$fresh = $oauth->refreshAccessToken($token);
$user->update(['xero_access_token' => json_encode($fresh)]); // new refresh token included
}
Persist the whole new token object — Xero rotates the refresh token on every refresh.
Build a tenant switcher early. A user can belong to multiple Xero orgs. getTenants($token) returns tenantId / tenantName / tenantType; let the user pick and store the selection. Design your data model so tenant_id is a column on the record, not on the user, if you ever sync more than one org.
Cache reference data hard. Accounts, tax rates, currencies, tracking categories, and branding themes change a few times a year but you'll query them on every page render. Cache::remember("xero:{$tenantId}:taxrates", 3600, fn() => $xero->taxRates) is the highest-leverage line of code in the integration.
Do the heavy lifting in queued jobs with throttling. Any bulk pull (all invoices for a year, full contact list) is many API calls against a 60-calls/minute-per-tenant limit. Use Redis::throttle('xero:'.$tenantId)->allow(50)->every(60) inside the job and let the queue absorb the backpressure.
Mirror to local tables rather than querying live in requests. The Eloquent-like syntax is pleasant but it is not a database — every call is a round trip, and the filter language is far weaker than SQL. The sustainable pattern is a scheduled sync job writing to real Eloquent models, with the live API used for point lookups (find($guid)) and writes. Your reporting queries should never touch Xero.
For testing, wrap it. There is no built-in fake, and because the SDK uses Guzzle directly, Http::fake() will not intercept it. Define a narrow interface (getContacts(), findInvoice($guid)) around the handful of calls you actually make, bind the real implementation, and swap in a fake in tests. This is worth the extra file.
It's Eloquent-like, not Eloquent. where(), first(), find() (GUID or array of GUIDs), and get() are the documented surface. Do not assume orWhere, whereIn, orderBy, limit, with(), paginate(), or chunk() exist or behave like SQL. Xero's own filter language only supports a subset of operators and AND-joins — no OR. If you need logic, filter the returned Collection in PHP.
Verify pagination before you trust get(). Xero caps most endpoints at 100 records per page. Test with a tenant that has more than 100 contacts before you build a dashboard on top of it. If get() doesn't walk pages, you'll need to window your queries by date (where('Date', '>=', 'DateTime(2024,01,01)')) or page manually.
Rate limits are per-tenant and enforced server-side. 60/minute and 5,000/day. The package has no retry, no backoff, no Retry-After handling. A busy sync job will 429 and throw. Catch, back off, and log. A multi-tenant app gets a separate bucket per tenant — nice, but also means one tenant's runaway job won't be visible in another tenant's metrics.
The README has a token-persistence inconsistency. Step 4 does json_encode($accessToken); the refresh example does $user->xero_access_token = $accessToken;. Follow the json_encode form everywhere. And when you read it back, use json_decode($json, true) — newer league/oauth2-client versions type-hint the AccessToken constructor as array, so passing the default stdClass throws a TypeError. The README's json_decode(...) without the second argument is a latent bug.
Encrypt tokens at rest. A Xero refresh token is a long-lived key to someone's accounting system. Use an encrypted cast or Crypt::encryptString. A plain TEXT column is a compliance problem waiting to happen.
Writes are undocumented. The README and relationship list are entirely about retrieval. Before you commit to a design where your app creates invoices or payments, confirm against the source whether XeroApp exposes any save/create path — and if not, plan to drop down to the official SDK or raw HTTP for the write side. Read with this package, write with the SDK, is a perfectly reasonable split.
Debugging: exceptions bubble up from the underlying SDK/Guzzle and are not especially contextual. Wrap your Xero calls in a small helper that catches, logs tenant_id, the relationship name, and the filter, then rethrows. That single log line will save you repeatedly.
Version support moves with Laravel. 3.2.0 added Laravel 9/10, 3.3.0 for 10, 3.4.0 for 11. Pin the package version against your Laravel version and check the release notes before a framework upgrade — the releases are almost entirely compatibility bumps, so there's no real feature risk, but a silent break is a silent break.
No webhook support. Xero emits webhooks for invoice/contact changes; this package doesn't handle them. If you need near-real-time sync, you'll implement the webhook endpoint and signature verification yourself, then use this package to fetch the changed entity.
How can I help you explore Laravel packages today?