webfox/laravel-xero-oauth2
A Laravel package for integrating Xero OAuth2 authentication. It streamlines the authorization flow, token storage and refresh, and authenticated Xero API requests, making it easier to connect Laravel applications to Xero.
1. Install and publish
composer require webfox/laravel-xero-oauth2
php artisan vendor:publish --provider="Webfox\Xero\XeroServiceProvider"
This gives you config/xero.php. Read it top to bottom before writing any code — it defines the client credentials, the scope list, the redirect URI, and which credential store is used.
2. Create the Xero app
In the Xero developer portal, create a Web app and set the redirect URI to the exact value you will put in XERO_REDIRECT_URI (scheme, host, path, and trailing slash all matter). Copy the client ID and secret into .env:
XERO_CLIENT_ID=
XERO_CLIENT_SECRET=
XERO_REDIRECT_URI="${APP_URL}/xero/callback"
3. Check the routes the package registers
php artisan route:list | grep -i xero
Typically you get an authorization entry point and a callback handler. If those routes are not what you want (e.g. you need them behind auth middleware), don't fight it — disable/ignore them and drive the flow yourself:
use Webfox\Xero\Xero;
Route::get('/connect/xero', fn (Xero $xero) => redirect($xero->getAuthorizationUrl()));
4. First use case: read something
use Webfox\Xero\Xero;
Route::get('/contacts', function (Xero $xero) {
abort_unless($xero->isAuthorized(), 403, 'Connect Xero first');
return collect($xero->getContacts())->map(fn ($c) => [
'name' => $c->getName(),
'email' => $c->getEmailAddress(),
]);
});
If this returns data, the whole OAuth + tenant + token-refresh loop is working. Everything else is the same shape: resolve Xero, call a method, get SDK models back.
Resolve, don't instantiate. The class is bound in the container and holds request-scoped state (access token, tenant). Inject Webfox\Xero\Xero into controllers, jobs, and commands. Avoid new Xero(...) and avoid stashing it in a singleton you built yourself.
Treat the token as infrastructure, not application state. The package's credential manager handles storing the access/refresh token and refreshing it near expiry. Your code should never read a raw token or build an Authorization header. Instead, expose a thin domain service so the rest of the app never sees OAuth:
class XeroContacts
{
public function __construct(private Xero $xero) {}
public function all(): array
{
return collect($this->xero->getContacts())
->map(fn ($c) => ContactData::fromXero($c))
->all();
}
}
This makes it trivial to swap in a fake in tests and gives you one place to add caching, logging, and retry logic.
Tenant handling. Almost every accounting endpoint requires a tenant ID (sent as the xero-tenant-id header). If your app serves a single organisation, the tenant is fixed at connect time and you can forget about it. If it's multi-tenant or multi-user, you must persist the tenant alongside the user and set it per request — check the credential manager's API for getTenantId() / setTenantId() and make sure your store is keyed by user, not global. Getting this wrong silently returns the first connected organisation's data, which is a serious data-leak class of bug.
Refresh-and-retry wrapper. Even with automatic refresh, a request in flight can 401 if the token rotated between check and call. Wrap calls:
try {
return $this->xero->getInvoices();
} catch (\Throwable $e) {
if ($this->isUnauthorized($e)) {
$this->xero->refreshAccessToken(); // if exposed by your version
return $this->xero->getInvoices();
}
throw $e;
}
If the refresh token itself is dead (Xero refresh tokens rotate and expire), catch it, mark the connection as needing re-auth, and redirect the user through the connect flow — do not retry in a loop.
Push bulk work to the queue. Xero is slow and rate-limited. A nightly sync of 5,000 contacts is a job, not a request. Use one job per logical chunk, and make each job idempotent (Xero's ContactID/InvoiceID are natural keys). Use ->onQueue('xero') so a slow sync can't starve your default queue, and set a generous $timeout — a cold SDK call over a large page easily exceeds 30s.
Prefer webhooks over polling for incremental sync. Subscribe to CONTACT, INVOICE, etc. Xero sends a small payload of IDs; you then fetch each entity. This is dramatically cheaper than If-Modified-Since polling and keeps your rate-limit budget free. Combine with a periodic full reconciliation, because webhooks are best-effort.
Multi-step auth in tests. For feature tests, bind a stub of the Xero class (or the credential manager interface) in the container. For anything that actually hits the API, use Xero's demo company — it resets monthly and won't pollute real books.
offline_access is mandatory for a refresh token. Omit it and users get one hour of access, then a hard re-auth prompt. Similarly, openid profile email is what lets the package enumerate tenants — without it the tenant lookup comes back empty and every API call fails with an unhelpful "no tenant" error.http vs https, www vs apex, and a trailing slash will all fail with invalid_request. Xero requires HTTPS except for localhost.php artisan config:cache freezes config/xero.php. If you switch credential stores or scopes, re-cache. Scopes are baked into the token at consent time — changing the scope list does not upgrade existing grants; users must reconnect.users table or a dedicated xero_connections table, encrypted at rest. This is also where you'd add "disconnect" and "reconnect" behaviour.Retry-After and an X-Rate-Limit-Problem header. Respect it, use exponential backoff, and never fire parallel requests at the same tenant from a queue worker pool.x-xero-signature (base64). $request->all() re-encoded will not match — use $request->getContent(). Also exclude the webhook route from CSRF and from any body-parsing middleware that mutates the payload.DateTime inconsistently across models, and amounts as floats. Cast explicitly at the boundary; don't trust == comparisons on monetary values coming from Xero.Xero class behind your own service (as above) so a breaking change or a needed fork costs you one file, not fifty call sites.isAuthorized() true? (2) tenant ID set and correct? (3) token expired and refresh failing? (4) scope missing for that endpoint? (5) 429 vs 401 vs 400 — the SDK's exception message usually carries Xero's Message/ValidationErrors, so log the full response body, not just getMessage().How can I help you explore Laravel packages today?