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 for every Xero API response
- Fluent builders for reads and writes
- Covers Accounting, Payroll, Files, Assets, Projects, Finance, App Store, Identity, and Webhooks
composer require sudiptpa/xero-php-sdkRequires:
- 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).
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');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);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');- 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
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(...).
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.
- Accounting
- Files
- Assets
- Projects
- Payroll AU
- Payroll NZ
- Payroll UK
- Finance
- App Store
- Identity
- Webhooks
- Architecture
- Auth
- Accounting
- Files
- Assets
- Projects
- Payroll AU
- Payroll NZ
- Payroll UK
- Finance
- App Store
- Webhooks
See Contributing and Changelog.