sudiptpa / xero-php-sdk
A fluent, framework-agnostic Xero PHP SDK for PHP 8.2 to 8.5 with rich models and a clean API.
Fund package maintenance!
Requires
- php: >=8.2 <8.6
Requires (Dev)
- laravel/pint: ^1.0
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-16 16:31:56 UTC
README
If this package saves you time, GitHub Sponsors is a simple way to support it.
A fluent, framework-agnostic Xero SDK for PHP 8.2 to 8.5. No runtime dependencies. Drop it into Laravel, Symfony, or a plain PHP project; it runs anywhere PHP runs.
- Typed models and array responses for Xero resources
- Fluent builders for reads and writes
- Covers Accounting, Payroll, Files, Assets, Projects, Finance, App Store, Identity, and Webhooks
Installation
composer require sudiptpa/xero-php-sdk
Requires:
- PHP 8.2 to 8.5
ext-jsonext-curl(for the built-in transport)
If ext-curl is not available, supply your own transport (see Custom transport).
Quick start
use Sujip\Xero\Auth\InMemoryTokenRepository; use Sujip\Xero\Xero; $manager = Xero::oauth2( clientId: 'client-id', clientSecret: 'client-secret', redirectUri: 'https://example.com/xero/callback', )->manager(new InMemoryTokenRepository()); $url = $manager->authorizationUrl( scopes: ['openid', 'offline_access', 'accounting.contacts'], state: 'csrf-token', );
After Xero redirects back with a code:
$token = $manager->exchange($code); $tenants = $manager->connections(); $connected = $manager->connectTenant($tenants[0]->getTenantId()); $contacts = $connected->tenant() ->accounting() ->contacts() ->page(1) ->get();
Call tenant() to get a client scoped to that tenant. getClient() works too.
If you already know the tenant id:
$connected = $manager->exchangeAndConnect($code, 'tenant-id');
Usage
use Sujip\Xero\Xero; $xero = Xero::withAccessToken('token') ->tenant('tenant-id'); $contacts = $xero->accounting() ->contacts() ->where('Name.Contains(:name)', name: 'Acme') ->orderBy('Name') ->page(1) ->get();
$page = $xero->accounting() ->contacts() ->paginate(page: 2);
use Sujip\Xero\Accounting\Invoice\Invoice; use Sujip\Xero\Accounting\Contact\Contact; use Sujip\Xero\Accounting\Invoice\LineItem; $invoice = $xero->accounting() ->invoices() ->create() ->using( (new Invoice()) ->setType('ACCREC') ->setStatus('DRAFT') ->setContact( (new Contact()) ->setContactID('contact-id') ) ->setReference('PO-1001') ->addLineItem( (new LineItem()) ->setDescription('Consulting') ->setQuantity(2) ->setUnitAmount(150) ) ) ->save();
use Sujip\Xero\Accounting\Account\Account; use Sujip\Xero\Accounting\Payment\Payment; $payment = $xero->accounting() ->payments() ->create() ->using( (new Payment()) ->setInvoiceID('invoice-id') ->setAccount( (new Account()) ->setAccountID('account-id') ) ->setDate('2026-03-25') ->setAmount(150) ->setReference('PAY-1001') ) ->save();
use Sujip\Xero\Accounting\Contact\Contact; $updated = $xero->accounting() ->contacts() ->update('contact-id') ->using( (new Contact()) ->setContactID('contact-id') ->setName('Acme Holdings Pty Ltd') ) ->save();
$attachment = $xero->accounting() ->invoices() ->attachments('invoice-id') ->upload('invoice.pdf', $pdfBinary) ->mimeType('application/pdf') ->includeOnline() ->save();
$file = $xero->files() ->upload('contract.pdf', $binary) ->mimeType('application/pdf') ->toFolder('folder-id') ->save(); $fileName = $file->getName();
$folder = $xero->files() ->folders() ->inbox(); $isInbox = $folder?->getIsInbox();
$files = $xero->files() ->forObject('invoice-id') ->get();
$assets = $xero->assets() ->status('registered') ->orderBy('AssetName') ->filterBy('MacBook') ->get(); $assetName = $assets->first()?->getAssetName();
$project = $xero->projects() ->create() ->title('Website rebuild') ->contact('contact-id') ->estimateAmount(1200) ->save(); $projectId = $project->getProjectId();
$entries = $xero->projects() ->timeEntries('project-id') ->user('user-id') ->task('task-id') ->states('INPROGRESS') ->get();
$employees = $xero->payroll() ->au() ->employees() ->page(1) ->get();
$leave = $xero->payroll() ->au() ->leaveApplications() ->create() ->employee('employee-id') ->leaveType('leave-type-id') ->title('Annual Leave') ->startDate('2026-04-01') ->endDate('2026-04-02') ->save();
$timesheet = $xero->payroll() ->nz() ->timesheets() ->create() ->employee('employee-id') ->startDate('2026-03-23') ->endDate('2026-03-29') ->status('DRAFT') ->save();
$balances = $xero->payroll() ->uk() ->employees() ->find('employee-id') ?->leaveBalances();
$balanceSheet = $xero->finance() ->statements() ->balanceSheet(new DateTimeImmutable('2026-03-31'));
$subscription = $xero->appStore() ->subscriptions() ->find('subscription-id');
$connections = Xero::withAccessToken($token) ->identity() ->connections() ->get();
$verifier = Xero::webhookVerifier($signingKey); $verifier->assertValid($rawPayload, $signatureHeader); $webhook = $verifier->parse($rawPayload);
Custom transport
Implement the Transport interface to use a different HTTP client.
use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\GuzzleException; use Sujip\Xero\Exceptions\TransportException; use Sujip\Xero\Http\Request; use Sujip\Xero\Http\Response; use Sujip\Xero\Http\Transport; use Sujip\Xero\Xero; final class GuzzleTransport implements Transport { public function __construct( private readonly GuzzleClient $client = new GuzzleClient() ) { } public function send(Request $request): Response { try { $response = $this->client->request($request->method, $request->url(), [ 'headers' => $request->headers, 'json' => $request->json, 'body' => $request->body, ]); } catch (GuzzleException $exception) { throw new TransportException($exception->getMessage(), previous: $exception); } return new Response( $response->getStatusCode(), array_map( static fn (array $values): string => $values[0] ?? '', $response->getHeaders() ), (string) $response->getBody(), ); } } $xero = Xero::withAccessToken('token', new GuzzleTransport()) ->tenant('tenant-id');
Scopes
- Apps created on or after 2 March 2026 must use granular scopes
- Apps created before 2 March 2026 can start requesting granular scopes from April 2026
- All apps must migrate off broad scopes by September 2027
- Request only the scopes the integration actually uses
- Use
.readscopes for read-only work - A missing scope returns a
401insufficient-scope response
Tenants
Use identity()->connections() to list which tenants a token can access. Make tenant-scoped API calls (Accounting, Files, Projects, Assets, Finance, Payroll) only after calling tenant(...).
Auth flow
use Sujip\Xero\Auth\InMemoryTokenRepository; use Sujip\Xero\Xero; $manager = Xero::oauth2( clientId: 'client-id', clientSecret: 'client-secret', redirectUri: 'https://example.com/xero/callback', )->manager(new InMemoryTokenRepository()); $url = $manager->authorizationUrl( scopes: ['openid', 'offline_access', 'accounting.contacts'], state: 'csrf-token', );
After callback:
$manager->exchange($code); $connected = $manager->connectTenant('tenant-id'); $xero = $connected->tenant();
See Auth for PKCE, token refresh, tenant selection, and custom connection flows.
Supported APIs
- Accounting
- Files
- Assets
- Projects
- Payroll AU
- Payroll NZ
- Payroll UK
- Finance
- App Store
- Identity
- Webhooks
Stability
This SDK is maintained against the official Xero OpenAPI specs and ships with full test coverage, static analysis, and formatting checks. Public API corrections that can affect existing callers are reserved for major releases. Patch and minor releases should remain backward compatible.
Documentation
- Field and method reference
- Architecture
- Auth
- Accounting
- Files
- Assets
- Projects
- Payroll AU
- Payroll NZ
- Payroll UK
- Finance
- App Store
- Webhooks
- Release process
Contributing
See Contributing, Security policy, and Changelog.