Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Xero Oauth2 Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • Redirect URI mismatch is the #1 support ticket. Xero compares byte-for-byte. http vs https, www vs apex, and a trailing slash will all fail with invalid_request. Xero requires HTTPS except for localhost.
  • Refresh tokens rotate on every use. If two processes refresh simultaneously, one wins and the other's token is silently invalid. Persist the new token before returning from the refresh call, and consider a cache lock around the refresh path in multi-worker deployments.
  • Config caching bites. 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.
  • Credential storage is the extension point. The default store is fine for a single-org internal tool but not for per-user tokens. Implement the package's credential-manager contract (and bind it in a service provider) to back tokens with your users table or a dedicated xero_connections table, encrypted at rest. This is also where you'd add "disconnect" and "reconnect" behaviour.
  • Rate limits are per-tenant and strict: roughly 60 calls/minute, 5,000/day, with a small concurrency cap. Xero returns 429 with 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.
  • Pagination is page-based (typically 100 records). Loop until you get fewer than a page, and guard with a max-page counter so a bad filter can't spin forever.
  • Webhook signature verification uses the raw body. Xero signs with HMAC-SHA256 over the exact bytes and sends 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.
  • Date and decimal handling. The SDK returns dates as strings/DateTime inconsistently across models, and amounts as floats. Cast explicitly at the boundary; don't trust == comparisons on monetary values coming from Xero.
  • Small package, so pin and read the changelog. With a modest star count and a single vendor maintaining it, upgrade deliberately. Wrap the Xero class behind your own service (as above) so a breaking change or a needed fork costs you one file, not fifty call sites.
  • Debugging checklist for a failing call: (1) 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().
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource