How to Connect Xero with Laravel?
Xero is the accounting platform of choice for a large number of small and medium-sized businesses, especially in the United Kingdom, Australia, and New Zealand. For applications built with Laravel, a Xero connection means invoices can be created automatically, contacts stay in sync, and financial data flows without manual work. Laravel is an excellent fit for this job: its HTTP client makes API calls concise, encrypted casts protect tokens at rest, cache locks prevent refresh conflicts, and queues and the scheduler handle background syncing. In this guide, we'll connect Xero with Laravel step by step, implementing OAuth 2.0 and API calls with Laravel's own tools, with code examples for every stage.
Before You Start
Start by creating an app in the Xero developer portal. Choose a web app and add your callback URL, for example http://localhost:8000/xero/callback for local development. The portal gives you a client ID and client secret. During development, connect to the Xero demo company, which comes with sample data and lets you test freely without touching real books.
One recent change is important to know: apps created on or after 2 March 2026 can no longer request the broad accounting.transactions scope. Instead, they must request granular scopes such as accounting.invoices or accounting.payments, choosing only the permissions the app actually needs. Existing apps have until September 2027 to migrate. Add your credentials to the environment and register them in config/services.php, so the rest of the code reads them through Laravel's configuration system.
# .env
XERO_CLIENT_ID=...
XERO_CLIENT_SECRET=...
XERO_REDIRECT_URI=http://localhost:8000/xero/callback
XERO_WEBHOOK_KEY=...
// config/services.php
'xero' => [
'client_id' => env('XERO_CLIENT_ID'),
'client_secret' => env('XERO_CLIENT_SECRET'),
'redirect' => env('XERO_REDIRECT_URI'),
'webhook_key' => env('XERO_WEBHOOK_KEY'),
],
Storing Connections Securely
Every connected Xero organisation, known as a tenant, needs its own record with the tenant ID and the current tokens. Xero access tokens are short-lived, while refresh tokens keep the connection alive over time, so both must be stored. Laravel's encrypted cast encrypts tokens automatically using your application key before they reach the database, so a leaked database backup doesn't expose working credentials. Use text columns, since Xero tokens are long and encryption increases their length further.
// database/migrations/xxxx_create_xero_connections_table.php
Schema::create('xero_connections', function (Blueprint $table) {
$table->id();
$table->string('tenant_id')->unique();
$table->string('tenant_name');
$table->text('access_token');
$table->text('refresh_token');
$table->timestamp('expires_at');
$table->timestamps();
});// app/Models/XeroConnection.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class XeroConnection extends Model
{
protected $fillable = [
'tenant_id', 'tenant_name', 'access_token', 'refresh_token', 'expires_at',
];
protected function casts(): array
{
return [
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'expires_at' => 'datetime',
];
}
}
Implementing the OAuth 2.0 Flow
The authorization flow needs two actions. The first generates a random state value, stores it in the session, and redirects the user to the Xero consent screen, where they review the requested permissions and choose which organisations to connect. The offline_access scope is required to receive a refresh token. The second action handles the callback: it verifies the state to protect against cross-site request forgery, exchanges the authorization code for tokens, fetches the connected organisations, and stores the connection.
// app/Http/Controllers/XeroController.php
namespace App\Http\Controllers;
use App\Models\XeroConnection;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class XeroController extends Controller
{
public function connect(Request $request)
{
$state = Str::random(40);
$request->session()->put('xero_state', $state);
$query = http_build_query([
'response_type' => 'code',
'client_id' => config('services.xero.client_id'),
'redirect_uri' => config('services.xero.redirect'),
'scope' => 'openid profile email accounting.contacts accounting.invoices offline_access',
'state' => $state,
]);
return redirect('https://login.xero.com/identity/connect/authorize?'.$query);
}
public function callback(Request $request)
{
abort_unless(
$request->state && $request->state === $request->session()->pull('xero_state'),
403
);
$tokens = Http::asForm()
->withBasicAuth(config('services.xero.client_id'), config('services.xero.client_secret'))
->post('https://identity.xero.com/connect/token', [
'grant_type' => 'authorization_code',
'code' => $request->code,
'redirect_uri' => config('services.xero.redirect'),
])
->throw()
->json();
// List the organisations (tenants) the user just authorised
$tenant = Http::withToken($tokens['access_token'])
->get('https://api.xero.com/connections')
->throw()
->json(0);
XeroConnection::updateOrCreate(
['tenant_id' => $tenant['tenantId']],
[
'tenant_name' => $tenant['tenantName'],
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds($tokens['expires_in']),
]
);
return redirect()->route('integrations')->with('status', 'Xero connected');
}
}// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/xero/connect', [XeroController::class, 'connect'])->name('xero.connect');
Route::get('/xero/callback', [XeroController::class, 'callback'])->name('xero.callback');
});For simplicity, this example stores the first connected organisation. If users can connect several organisations in one authorization, loop through the full connections list and store each tenant separately.
Making API Calls and Refreshing Tokens
A dedicated service class keeps all Xero logic in one place. Before each request, it checks whether the access token is about to expire and refreshes it if needed. Because refreshing returns a new refresh token, the update must be saved immediately, and two queue workers must never refresh the same connection at once. A cache lock solves this: the first worker refreshes, and any other worker waits, reloads the record, and uses the fresh token. Note that cache locks require a cache driver that supports them, such as Redis, Memcached, or the database driver.
// app/Services/XeroClient.php
namespace App\Services;
use App\Models\XeroConnection;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class XeroClient
{
public function __construct(private XeroConnection $connection) {}
public function request(): PendingRequest
{
$this->refreshTokenIfNeeded();
return Http::withToken($this->connection->access_token)
->withHeaders(['xero-tenant-id' => $this->connection->tenant_id])
->acceptJson()
->baseUrl('https://api.xero.com/api.xro/2.0')
->retry(3, 2000, fn ($e) => $e instanceof RequestException
&& $e->response->status() === 429);
}
public function createDraftInvoice(string $contactId, float $amount): array
{
return $this->request()
->post('/Invoices', [
'Invoices' => [[
'Type' => 'ACCREC',
'Contact' => ['ContactID' => $contactId],
'LineAmountTypes' => 'Exclusive',
'Status' => 'DRAFT',
'LineItems' => [[
'Description' => 'Consulting services',
'Quantity' => 1,
'UnitAmount' => $amount,
'AccountCode' => '200',
]],
]],
])
->throw()
->json('Invoices.0');
}
private function refreshTokenIfNeeded(): void
{
if ($this->connection->expires_at->isAfter(now()->addMinute())) {
return;
}
// Only one worker may refresh at a time
Cache::lock("xero-refresh-{$this->connection->id}", 10)->block(5, function () {
$this->connection->refresh(); // another worker may have refreshed already
if ($this->connection->expires_at->isAfter(now()->addMinute())) {
return;
}
$tokens = Http::asForm()
->withBasicAuth(config('services.xero.client_id'), config('services.xero.client_secret'))
->post('https://identity.xero.com/connect/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $this->connection->refresh_token,
])
->throw()
->json();
$this->connection->update([
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds($tokens['expires_in']),
]);
});
}
}The example creates a draft sales invoice, a safe default until the business is ready to approve invoices automatically. The built-in retry handles 429 rate-limit responses gracefully, and every call automatically carries the xero-tenant-id header that tells Xero which organisation to use.
Receiving Xero Webhooks
Instead of repeatedly polling for changes, your application can subscribe to Xero webhooks for events such as created or updated contacts and invoices. Each request carries an x-xero-signature header, an HMAC-SHA256 hash of the raw body created with your webhook key. When you first register the endpoint, Xero sends an intent-to-receive validation: your route must return 200 for correctly signed requests and 401 for incorrectly signed ones, or the subscription won't be activated.
Xero expects a fast response, so the controller only verifies the signature and dispatches each event to a queued job. Webhook payloads contain references to changed resources rather than full records, so the job then fetches the latest data through the service class. The webhook route must also be excluded from CSRF protection.
// app/Http/Controllers/XeroWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ProcessXeroEvent;
use Illuminate\Http\Request;
class XeroWebhookController extends Controller
{
public function __invoke(Request $request)
{
$expected = base64_encode(hash_hmac(
'sha256',
$request->getContent(),
config('services.xero.webhook_key'),
true
));
if (! hash_equals($expected, (string) $request->header('x-xero-signature'))) {
return response('', 401);
}
// Respond fast: fetch full records later in queued jobs
foreach ($request->json('events', []) as $event) {
ProcessXeroEvent::dispatch($event);
}
return response('', 200);
}
}
// routes/web.php
Route::post('/webhooks/xero', XeroWebhookController::class);
// bootstrap/app.php
$middleware->validateCsrfTokens(except: ['webhooks/*']);
Best Practices for Production
Xero enforces rate limits per organisation, including per-minute and daily call limits and a limit on concurrent requests. Move large syncs into queued jobs, fetch only records that have changed, and use Laravel's rate limiting or job middleware to keep throughput within limits. The scheduler is a natural place for periodic reconciliation jobs that catch anything a webhook might have missed.
Request only the granular scopes you need, handle disconnections gracefully when a user revokes access from their Xero settings, and log every sync with enough context to trace problems quickly. Alerting on failed jobs ensures that sync issues are noticed immediately rather than at month-end.
Conclusion
Connecting Xero with Laravel gives your application reliable, automated access to your customers' accounting data using tools the framework already provides. By registering an app with granular scopes, implementing OAuth 2.0 with state verification, encrypting tokens at rest, refreshing them safely with cache locks, and processing webhooks through queues, you build an integration that stays dependable as usage grows. Whether you're adding invoicing to a SaaS product or automating an internal finance workflow, getting the integration right is essential, and working with an experienced team that understands both Xero and software architecture ensures your financial data stays accurate and secure.